From f7b32ce723a85f8e968acd820c062bffc69ec511 Mon Sep 17 00:00:00 2001
From: Gabriele Bartolini <gabriele.bartolini@enterprisedb.com>
Date: Fri, 21 Aug 2026 12:39:37 +1000
Subject: [PATCH] Track role modification timestamp in pg_authid.rollastupdated

Add a rollastupdated timestamptz column to pg_authid, recording when the
most recent CREATE ROLE or ALTER ROLE command for a role was executed.
Tools that manage roles declaratively (Kubernetes operators, Ansible,
Terraform) currently have no cheap way to ask whether a role changed
since they last looked, and must either re-issue ALTER ROLE
unconditionally on every reconcile pass or reimplement attribute-by-
attribute diffing client side.

The timestamp is set by CreateRole(), AlterRole() and RenameRole(), and
by AlterRoleSet() for ALTER ROLE ... SET/RESET, since role-level
configuration parameters are role state too even though they are stored
in pg_db_role_setting.  The value advances whenever such a command
completes successfully, even if it did not actually change anything:
for passwords this is not merely a simplification but a necessity, as
pg_be_scram_build_secret() generates a fresh random salt per call, so
re-issuing an identical password cannot be distinguished from a change.

The column is null for roles created during initdb that have never been
altered.  It is not preserved by pg_dumpall or pg_upgrade, which replay
CREATE ROLE and ALTER ROLE against the target cluster, so restored roles
carry the time of the restore.

Exposed as rollastupdated in pg_roles and as lastupdated in pg_shadow
and pg_user.

Assisted-by: Claude

Signed-off-by: Gabriele Bartolini <gabriele.bartolini@enterprisedb.com>
---
 doc/src/sgml/catalogs.sgml                    | 17 ++++
 doc/src/sgml/system-views.sgml                | 36 +++++++++
 src/backend/catalog/system_views.sql          |  9 ++-
 src/backend/commands/user.c                   | 67 +++++++++++++++
 src/include/catalog/catversion.h              |  2 +-
 src/include/catalog/pg_authid.dat             | 51 ++++++++----
 src/include/catalog/pg_authid.h               |  2 +
 .../regress/expected/role_lastupdated.out     | 81 +++++++++++++++++++
 src/test/regress/expected/rules.out           |  9 ++-
 src/test/regress/parallel_schedule            |  2 +-
 src/test/regress/sql/role_lastupdated.sql     | 53 ++++++++++++
 11 files changed, 304 insertions(+), 25 deletions(-)
 create mode 100644 src/test/regress/expected/role_lastupdated.out
 create mode 100644 src/test/regress/sql/role_lastupdated.sql

diff --git a/doc/src/sgml/catalogs.sgml b/doc/src/sgml/catalogs.sgml
index 6066c4784f4..208432c88fb 100644
--- a/doc/src/sgml/catalogs.sgml
+++ b/doc/src/sgml/catalogs.sgml
@@ -1624,6 +1624,23 @@
        null if no expiration
       </para></entry>
      </row>
+
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>rollastupdated</structfield> <type>timestamptz</type>
+      </para>
+      <para>
+       Time at which the most recent <xref linkend="sql-createrole"/> or
+       <xref linkend="sql-alterrole"/> command for this role was executed
+       (including a rename or a role-level configuration parameter
+       change). The timestamp advances whenever such a command completes
+       successfully, even if it did not change any value. Null for roles
+       created during <application>initdb</application> that have never
+       been altered. Not preserved by <application>pg_dumpall</application>
+       or <application>pg_upgrade</application>, which recreate roles in
+       the target cluster.
+      </para></entry>
+     </row>
     </tbody>
    </tgroup>
   </table>
diff --git a/doc/src/sgml/system-views.sgml b/doc/src/sgml/system-views.sgml
index 5ea19d68622..550e470a2d3 100644
--- a/doc/src/sgml/system-views.sgml
+++ b/doc/src/sgml/system-views.sgml
@@ -3333,6 +3333,18 @@ SELECT * FROM pg_locks pl LEFT JOIN pg_prepared_xacts ppx
        ID of role
       </para></entry>
      </row>
+
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>rollastupdated</structfield> <type>timestamptz</type>
+      </para>
+      <para>
+       Time at which the most recent <xref linkend="sql-createrole"/> or
+       <xref linkend="sql-alterrole"/> command for this role was executed,
+       even if it did not change any value. Null for roles created during
+       <application>initdb</application> that have never been altered
+      </para></entry>
+     </row>
     </tbody>
    </tgroup>
   </table>
@@ -4165,6 +4177,18 @@ SELECT * FROM pg_locks pl LEFT JOIN pg_prepared_xacts ppx
        Session defaults for run-time configuration variables
       </para></entry>
      </row>
+
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>lastupdated</structfield> <type>timestamptz</type>
+      </para>
+      <para>
+       Time at which the most recent <xref linkend="sql-createrole"/> or
+       <xref linkend="sql-alterrole"/> command for this role was executed,
+       even if it did not change any value. Null for roles created during
+       <application>initdb</application> that have never been altered
+      </para></entry>
+     </row>
     </tbody>
    </tgroup>
   </table>
@@ -5529,6 +5553,18 @@ SELECT * FROM pg_locks pl LEFT JOIN pg_prepared_xacts ppx
        Session defaults for run-time configuration variables
       </para></entry>
      </row>
+
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>lastupdated</structfield> <type>timestamptz</type>
+      </para>
+      <para>
+       Time at which the most recent <xref linkend="sql-createrole"/> or
+       <xref linkend="sql-alterrole"/> command for this role was executed,
+       even if it did not change any value; null for roles created during
+       <application>initdb</application> that have never been altered
+      </para></entry>
+     </row>
     </tbody>
    </tgroup>
   </table>
diff --git a/src/backend/catalog/system_views.sql b/src/backend/catalog/system_views.sql
index 8612d99a890..802fa8ca056 100644
--- a/src/backend/catalog/system_views.sql
+++ b/src/backend/catalog/system_views.sql
@@ -33,7 +33,8 @@ CREATE VIEW pg_roles AS
         rolvaliduntil,
         rolbypassrls,
         setconfig as rolconfig,
-        pg_authid.oid
+        pg_authid.oid,
+        rollastupdated
     FROM pg_authid LEFT JOIN pg_db_role_setting s
     ON (pg_authid.oid = setrole AND setdatabase = 0);
 
@@ -47,7 +48,8 @@ CREATE VIEW pg_shadow AS
         rolbypassrls AS usebypassrls,
         rolpassword AS passwd,
         rolvaliduntil AS valuntil,
-        setconfig AS useconfig
+        setconfig AS useconfig,
+        rollastupdated AS lastupdated
     FROM pg_authid LEFT JOIN pg_db_role_setting s
     ON (pg_authid.oid = setrole AND setdatabase = 0)
     WHERE rolcanlogin;
@@ -72,7 +74,8 @@ CREATE VIEW pg_user AS
         usebypassrls,
         '********'::text as passwd,
         valuntil,
-        useconfig
+        useconfig,
+        lastupdated
     FROM pg_shadow;
 
 CREATE VIEW pg_policies AS
diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 04b270c08a8..26eb41fff4a 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -39,6 +39,7 @@
 #include "utils/catcache.h"
 #include "utils/fmgroids.h"
 #include "utils/syscache.h"
+#include "utils/timestamp.h"
 #include "utils/varlena.h"
 
 /*
@@ -116,6 +117,7 @@ static void plan_recursive_revoke(CatCList *memlist,
 								  bool revoke_admin_option_only,
 								  DropBehavior behavior);
 static void InitGrantRoleOptions(GrantRoleOptions *popt);
+static void update_role_lastupdated(Oid roleid);
 
 
 /* Check if current user has createrole privileges */
@@ -463,6 +465,9 @@ CreateRole(ParseState *pstate, CreateRoleStmt *stmt)
 
 	new_record[Anum_pg_authid_rolbypassrls - 1] = BoolGetDatum(bypassrls);
 
+	new_record[Anum_pg_authid_rollastupdated - 1] =
+		TimestampTzGetDatum(GetCurrentTimestamp());
+
 	/*
 	 * pg_largeobject_metadata contains pg_authid.oid's, so we use the
 	 * binary-upgrade override.
@@ -960,6 +965,14 @@ AlterRole(ParseState *pstate, AlterRoleStmt *stmt)
 		new_record_repl[Anum_pg_authid_rolbypassrls - 1] = true;
 	}
 
+	/*
+	 * Record that an ALTER ROLE command was executed for this role.
+	 * The timestamp advances even when the command did not actually change any value.
+	 */
+	new_record[Anum_pg_authid_rollastupdated - 1] =
+		TimestampTzGetDatum(GetCurrentTimestamp());
+	new_record_repl[Anum_pg_authid_rollastupdated - 1] = true;
+
 	new_tuple = heap_modify_tuple(tuple, pg_authid_dsc, new_record,
 								  new_record_nulls, new_record_repl);
 	CatalogTupleUpdate(pg_authid_rel, &tuple->t_self, new_tuple);
@@ -999,6 +1012,48 @@ AlterRole(ParseState *pstate, AlterRoleStmt *stmt)
 	return roleid;
 }
 
+/*
+ * Set pg_authid.rollastupdated for the specified role to the current timestamp.
+ *
+ * Used by auxiliary commands (like ALTER ROLE ... SET) to ensure all role
+ * modifications update the timestamp consistently.
+ *
+ */
+static void
+update_role_lastupdated(Oid roleid)
+{
+	Relation	pg_authid_rel;
+	TupleDesc	pg_authid_dsc;
+	HeapTuple	tuple,
+				new_tuple;
+	Datum		new_record[Natts_pg_authid] = {0};
+	bool		new_record_nulls[Natts_pg_authid] = {0};
+	bool		new_record_repl[Natts_pg_authid] = {0};
+
+	pg_authid_rel = table_open(AuthIdRelationId, RowExclusiveLock);
+	pg_authid_dsc = RelationGetDescr(pg_authid_rel);
+
+	/*
+	 * The caller is expected to have already verified that the role exists
+	 * and locked it, so a cache miss here indicates a bug.
+	 */
+	tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(roleid));
+	if (!HeapTupleIsValid(tuple))
+		elog(ERROR, "cache lookup failed for role %u", roleid);
+
+	new_record[Anum_pg_authid_rollastupdated - 1] =
+		TimestampTzGetDatum(GetCurrentTimestamp());
+	new_record_repl[Anum_pg_authid_rollastupdated - 1] = true;
+
+	new_tuple = heap_modify_tuple(tuple, pg_authid_dsc, new_record,
+								  new_record_nulls, new_record_repl);
+	CatalogTupleUpdate(pg_authid_rel, &tuple->t_self, new_tuple);
+
+	ReleaseSysCache(tuple);
+	heap_freetuple(new_tuple);
+
+	table_close(pg_authid_rel, NoLock);
+}
 
 /*
  * ALTER ROLE ... SET
@@ -1086,6 +1141,13 @@ AlterRoleSet(AlterRoleSetStmt *stmt)
 
 	AlterSetting(databaseid, roleid, stmt->setstmt);
 
+	/*
+	 * ALTER ROLE ... SET alters role state outside AlterRole(), so update
+	 * rollastupdated here. Ignore if stmt->role is NULL (global/database SET).
+	 */
+	if (OidIsValid(roleid))
+		update_role_lastupdated(roleid);
+
 	return roleid;
 }
 
@@ -1467,6 +1529,11 @@ RenameRole(const char *oldname, const char *newname)
 				(errmsg("MD5 password cleared because of role rename")));
 	}
 
+	repl_repl[Anum_pg_authid_rollastupdated - 1] = true;
+	repl_val[Anum_pg_authid_rollastupdated - 1] =
+		TimestampTzGetDatum(GetCurrentTimestamp());
+	repl_null[Anum_pg_authid_rollastupdated - 1] = false;
+
 	newtuple = heap_modify_tuple(oldtuple, dsc, repl_val, repl_null, repl_repl);
 	CatalogTupleUpdate(rel, &oldtuple->t_self, newtuple);
 
diff --git a/src/include/catalog/catversion.h b/src/include/catalog/catversion.h
index 31e49d5a8a7..17661cdedf3 100644
--- a/src/include/catalog/catversion.h
+++ b/src/include/catalog/catversion.h
@@ -57,6 +57,6 @@
  */
 
 /*							yyyymmddN */
-#define CATALOG_VERSION_NO	202608183
+#define CATALOG_VERSION_NO	202608201
 
 #endif
diff --git a/src/include/catalog/pg_authid.dat b/src/include/catalog/pg_authid.dat
index 2960707c729..0ba6e56f40b 100644
--- a/src/include/catalog/pg_authid.dat
+++ b/src/include/catalog/pg_authid.dat
@@ -25,86 +25,103 @@
   rolname => 'POSTGRES', rolsuper => 't', rolinherit => 't',
   rolcreaterole => 't', rolcreatedb => 't', rolcanlogin => 't',
   rolreplication => 't', rolbypassrls => 't', rolconnlimit => '-1',
-  rolpassword => '_null_', rolvaliduntil => '_null_' },
+  rolpassword => '_null_', rolvaliduntil => '_null_',
+  rollastupdated => '_null_' },
 { oid => '6171', oid_symbol => 'ROLE_PG_DATABASE_OWNER',
   rolname => 'pg_database_owner', rolsuper => 'f', rolinherit => 't',
   rolcreaterole => 'f', rolcreatedb => 'f', rolcanlogin => 'f',
   rolreplication => 'f', rolbypassrls => 'f', rolconnlimit => '-1',
-  rolpassword => '_null_', rolvaliduntil => '_null_' },
+  rolpassword => '_null_', rolvaliduntil => '_null_',
+  rollastupdated => '_null_' },
 { oid => '6181', oid_symbol => 'ROLE_PG_READ_ALL_DATA',
   rolname => 'pg_read_all_data', rolsuper => 'f', rolinherit => 't',
   rolcreaterole => 'f', rolcreatedb => 'f', rolcanlogin => 'f',
   rolreplication => 'f', rolbypassrls => 'f', rolconnlimit => '-1',
-  rolpassword => '_null_', rolvaliduntil => '_null_' },
+  rolpassword => '_null_', rolvaliduntil => '_null_',
+  rollastupdated => '_null_' },
 { oid => '6182', oid_symbol => 'ROLE_PG_WRITE_ALL_DATA',
   rolname => 'pg_write_all_data', rolsuper => 'f', rolinherit => 't',
   rolcreaterole => 'f', rolcreatedb => 'f', rolcanlogin => 'f',
   rolreplication => 'f', rolbypassrls => 'f', rolconnlimit => '-1',
-  rolpassword => '_null_', rolvaliduntil => '_null_' },
+  rolpassword => '_null_', rolvaliduntil => '_null_',
+  rollastupdated => '_null_' },
 { oid => '3373', oid_symbol => 'ROLE_PG_MONITOR',
   rolname => 'pg_monitor', rolsuper => 'f', rolinherit => 't',
   rolcreaterole => 'f', rolcreatedb => 'f', rolcanlogin => 'f',
   rolreplication => 'f', rolbypassrls => 'f', rolconnlimit => '-1',
-  rolpassword => '_null_', rolvaliduntil => '_null_' },
+  rolpassword => '_null_', rolvaliduntil => '_null_',
+  rollastupdated => '_null_' },
 { oid => '3374', oid_symbol => 'ROLE_PG_READ_ALL_SETTINGS',
   rolname => 'pg_read_all_settings', rolsuper => 'f', rolinherit => 't',
   rolcreaterole => 'f', rolcreatedb => 'f', rolcanlogin => 'f',
   rolreplication => 'f', rolbypassrls => 'f', rolconnlimit => '-1',
-  rolpassword => '_null_', rolvaliduntil => '_null_' },
+  rolpassword => '_null_', rolvaliduntil => '_null_',
+  rollastupdated => '_null_' },
 { oid => '3375', oid_symbol => 'ROLE_PG_READ_ALL_STATS',
   rolname => 'pg_read_all_stats', rolsuper => 'f', rolinherit => 't',
   rolcreaterole => 'f', rolcreatedb => 'f', rolcanlogin => 'f',
   rolreplication => 'f', rolbypassrls => 'f', rolconnlimit => '-1',
-  rolpassword => '_null_', rolvaliduntil => '_null_' },
+  rolpassword => '_null_', rolvaliduntil => '_null_',
+  rollastupdated => '_null_' },
 { oid => '3377', oid_symbol => 'ROLE_PG_STAT_SCAN_TABLES',
   rolname => 'pg_stat_scan_tables', rolsuper => 'f', rolinherit => 't',
   rolcreaterole => 'f', rolcreatedb => 'f', rolcanlogin => 'f',
   rolreplication => 'f', rolbypassrls => 'f', rolconnlimit => '-1',
-  rolpassword => '_null_', rolvaliduntil => '_null_' },
+  rolpassword => '_null_', rolvaliduntil => '_null_',
+  rollastupdated => '_null_' },
 { oid => '4569', oid_symbol => 'ROLE_PG_READ_SERVER_FILES',
   rolname => 'pg_read_server_files', rolsuper => 'f', rolinherit => 't',
   rolcreaterole => 'f', rolcreatedb => 'f', rolcanlogin => 'f',
   rolreplication => 'f', rolbypassrls => 'f', rolconnlimit => '-1',
-  rolpassword => '_null_', rolvaliduntil => '_null_' },
+  rolpassword => '_null_', rolvaliduntil => '_null_',
+  rollastupdated => '_null_' },
 { oid => '4570', oid_symbol => 'ROLE_PG_WRITE_SERVER_FILES',
   rolname => 'pg_write_server_files', rolsuper => 'f', rolinherit => 't',
   rolcreaterole => 'f', rolcreatedb => 'f', rolcanlogin => 'f',
   rolreplication => 'f', rolbypassrls => 'f', rolconnlimit => '-1',
-  rolpassword => '_null_', rolvaliduntil => '_null_' },
+  rolpassword => '_null_', rolvaliduntil => '_null_',
+  rollastupdated => '_null_' },
 { oid => '4571', oid_symbol => 'ROLE_PG_EXECUTE_SERVER_PROGRAM',
   rolname => 'pg_execute_server_program', rolsuper => 'f', rolinherit => 't',
   rolcreaterole => 'f', rolcreatedb => 'f', rolcanlogin => 'f',
   rolreplication => 'f', rolbypassrls => 'f', rolconnlimit => '-1',
-  rolpassword => '_null_', rolvaliduntil => '_null_' },
+  rolpassword => '_null_', rolvaliduntil => '_null_',
+  rollastupdated => '_null_' },
 { oid => '4200', oid_symbol => 'ROLE_PG_SIGNAL_BACKEND',
   rolname => 'pg_signal_backend', rolsuper => 'f', rolinherit => 't',
   rolcreaterole => 'f', rolcreatedb => 'f', rolcanlogin => 'f',
   rolreplication => 'f', rolbypassrls => 'f', rolconnlimit => '-1',
-  rolpassword => '_null_', rolvaliduntil => '_null_' },
+  rolpassword => '_null_', rolvaliduntil => '_null_',
+  rollastupdated => '_null_' },
 { oid => '4544', oid_symbol => 'ROLE_PG_CHECKPOINT',
   rolname => 'pg_checkpoint', rolsuper => 'f', rolinherit => 't',
   rolcreaterole => 'f', rolcreatedb => 'f', rolcanlogin => 'f',
   rolreplication => 'f', rolbypassrls => 'f', rolconnlimit => '-1',
-  rolpassword => '_null_', rolvaliduntil => '_null_' },
+  rolpassword => '_null_', rolvaliduntil => '_null_',
+  rollastupdated => '_null_' },
 { oid => '6337', oid_symbol => 'ROLE_PG_MAINTAIN',
   rolname => 'pg_maintain', rolsuper => 'f', rolinherit => 't',
   rolcreaterole => 'f', rolcreatedb => 'f', rolcanlogin => 'f',
   rolreplication => 'f', rolbypassrls => 'f', rolconnlimit => '-1',
-  rolpassword => '_null_', rolvaliduntil => '_null_' },
+  rolpassword => '_null_', rolvaliduntil => '_null_',
+  rollastupdated => '_null_' },
 { oid => '4550', oid_symbol => 'ROLE_PG_USE_RESERVED_CONNECTIONS',
   rolname => 'pg_use_reserved_connections', rolsuper => 'f', rolinherit => 't',
   rolcreaterole => 'f', rolcreatedb => 'f', rolcanlogin => 'f',
   rolreplication => 'f', rolbypassrls => 'f', rolconnlimit => '-1',
-  rolpassword => '_null_', rolvaliduntil => '_null_' },
+  rolpassword => '_null_', rolvaliduntil => '_null_',
+  rollastupdated => '_null_' },
 { oid => '6304', oid_symbol => 'ROLE_PG_CREATE_SUBSCRIPTION',
   rolname => 'pg_create_subscription', rolsuper => 'f', rolinherit => 't',
   rolcreaterole => 'f', rolcreatedb => 'f', rolcanlogin => 'f',
   rolreplication => 'f', rolbypassrls => 'f', rolconnlimit => '-1',
-  rolpassword => '_null_', rolvaliduntil => '_null_' },
+  rolpassword => '_null_', rolvaliduntil => '_null_',
+  rollastupdated => '_null_' },
 { oid => '6392', oid_symbol => 'ROLE_PG_SIGNAL_AUTOVACUUM_WORKER',
   rolname => 'pg_signal_autovacuum_worker', rolsuper => 'f', rolinherit => 't',
   rolcreaterole => 'f', rolcreatedb => 'f', rolcanlogin => 'f',
   rolreplication => 'f', rolbypassrls => 'f', rolconnlimit => '-1',
-  rolpassword => '_null_', rolvaliduntil => '_null_' },
+  rolpassword => '_null_', rolvaliduntil => '_null_',
+  rollastupdated => '_null_' },
 
 ]
diff --git a/src/include/catalog/pg_authid.h b/src/include/catalog/pg_authid.h
index baf5b099797..ade9b078986 100644
--- a/src/include/catalog/pg_authid.h
+++ b/src/include/catalog/pg_authid.h
@@ -47,6 +47,8 @@ CATALOG(pg_authid,1260,AuthIdRelationId) BKI_SHARED_RELATION BKI_ROWTYPE_OID(284
 #ifdef CATALOG_VARLEN			/* variable-length fields start here */
 	text		rolpassword;	/* password, if any */
 	timestamptz rolvaliduntil;	/* password expiration time, if any */
+	timestamptz rollastupdated;	/* time of last CREATE ROLE / ALTER ROLE
+									 * on this role, if any */
 #endif
 } FormData_pg_authid;
 
diff --git a/src/test/regress/expected/role_lastupdated.out b/src/test/regress/expected/role_lastupdated.out
new file mode 100644
index 00000000000..8bea8d6a6cf
--- /dev/null
+++ b/src/test/regress/expected/role_lastupdated.out
@@ -0,0 +1,81 @@
+--
+-- Tests for pg_authid.rollastupdated
+--
+-- rollastupdated records when the most recent CREATE ROLE or ALTER ROLE
+-- command for a role was executed, so that tools managing roles
+-- declaratively can cheaply detect that a role may have changed.
+--
+-- Each command below is checked twice against the timestamp recorded
+-- before it ran: first while nothing has happened yet, where no row
+-- should qualify, and again afterwards, where the role should appear.
+--
+-- roles created during initdb have no recorded modification time
+SELECT rollastupdated IS NULL AS bootstrap_role_is_null
+    FROM pg_authid WHERE rolname = 'pg_read_all_data';
+ bootstrap_role_is_null 
+------------------------
+ t
+(1 row)
+
+-- CREATE ROLE records a timestamp
+CREATE ROLE regress_lastupdated_role;
+SELECT rollastupdated IS NOT NULL AS create_sets_lastupdated
+    FROM pg_authid WHERE rolname = 'regress_lastupdated_role';
+ create_sets_lastupdated 
+-------------------------
+ t
+(1 row)
+
+SELECT rollastupdated AS ts_created
+    FROM pg_authid WHERE rolname = 'regress_lastupdated_role' \gset
+-- changing an attribute advances it
+SELECT rolname FROM pg_authid
+    WHERE rolname = 'regress_lastupdated_role' AND rollastupdated > :'ts_created';
+ rolname 
+---------
+(0 rows)
+
+ALTER ROLE regress_lastupdated_role WITH CONNECTION LIMIT 5;
+SELECT rolname FROM pg_authid
+    WHERE rolname = 'regress_lastupdated_role' AND rollastupdated > :'ts_created';
+         rolname          
+--------------------------
+ regress_lastupdated_role
+(1 row)
+
+SELECT rollastupdated AS ts_altered
+    FROM pg_authid WHERE rolname = 'regress_lastupdated_role' \gset
+-- ALTER ROLE ... SET stores the setting outside pg_authid, but still
+-- advances it
+SELECT rolname FROM pg_authid
+    WHERE rolname = 'regress_lastupdated_role' AND rollastupdated > :'ts_altered';
+ rolname 
+---------
+(0 rows)
+
+ALTER ROLE regress_lastupdated_role SET work_mem = '10MB';
+SELECT rolname FROM pg_authid
+    WHERE rolname = 'regress_lastupdated_role' AND rollastupdated > :'ts_altered';
+         rolname          
+--------------------------
+ regress_lastupdated_role
+(1 row)
+
+SELECT rollastupdated AS ts_set
+    FROM pg_authid WHERE rolname = 'regress_lastupdated_role' \gset
+-- a rename advances it too, and the value is visible through pg_roles
+SELECT rolname FROM pg_roles
+    WHERE rolname = 'regress_lastupdated_role' AND rollastupdated > :'ts_set';
+ rolname 
+---------
+(0 rows)
+
+ALTER ROLE regress_lastupdated_role RENAME TO regress_lastupdated_role2;
+SELECT rolname FROM pg_roles
+    WHERE rolname = 'regress_lastupdated_role2' AND rollastupdated > :'ts_set';
+          rolname          
+---------------------------
+ regress_lastupdated_role2
+(1 row)
+
+DROP ROLE regress_lastupdated_role2;
diff --git a/src/test/regress/expected/rules.out b/src/test/regress/expected/rules.out
index 1a29d46213e..41512033566 100644
--- a/src/test/regress/expected/rules.out
+++ b/src/test/regress/expected/rules.out
@@ -1525,7 +1525,8 @@ pg_roles| SELECT pg_authid.rolname,
     pg_authid.rolvaliduntil,
     pg_authid.rolbypassrls,
     s.setconfig AS rolconfig,
-    pg_authid.oid
+    pg_authid.oid,
+    pg_authid.rollastupdated
    FROM (pg_authid
      LEFT JOIN pg_db_role_setting s ON (((pg_authid.oid = s.setrole) AND (s.setdatabase = (0)::oid))));
 pg_rules| SELECT n.nspname AS schemaname,
@@ -1763,7 +1764,8 @@ pg_shadow| SELECT pg_authid.rolname AS usename,
     pg_authid.rolbypassrls AS usebypassrls,
     pg_authid.rolpassword AS passwd,
     pg_authid.rolvaliduntil AS valuntil,
-    s.setconfig AS useconfig
+    s.setconfig AS useconfig,
+    pg_authid.rollastupdated AS lastupdated
    FROM (pg_authid
      LEFT JOIN pg_db_role_setting s ON (((pg_authid.oid = s.setrole) AND (s.setdatabase = (0)::oid))))
   WHERE pg_authid.rolcanlogin;
@@ -2876,7 +2878,8 @@ pg_user| SELECT usename,
     usebypassrls,
     '********'::text AS passwd,
     valuntil,
-    useconfig
+    useconfig,
+    lastupdated
    FROM pg_shadow;
 pg_user_mappings| SELECT u.oid AS umid,
     s.oid AS srvid,
diff --git a/src/test/regress/parallel_schedule b/src/test/regress/parallel_schedule
index 8fa0a6c47fb..f27e642d181 100644
--- a/src/test/regress/parallel_schedule
+++ b/src/test/regress/parallel_schedule
@@ -76,7 +76,7 @@ test: brin_bloom brin_multi
 # ----------
 # Another group of parallel tests
 # ----------
-test: create_table_like alter_generic alter_operator misc async dbsize merge misc_functions nls sysviews tsrf tid tidscan tidrangescan collate.utf8 collate.icu.utf8 incremental_sort create_role without_overlaps generated_virtual
+test: create_table_like alter_generic alter_operator misc async dbsize merge misc_functions nls sysviews tsrf tid tidscan tidrangescan collate.utf8 collate.icu.utf8 incremental_sort create_role role_lastupdated without_overlaps generated_virtual
 
 # collate.linux.utf8 and collate.icu.utf8 tests cannot be run in parallel with each other
 # psql depends on create_am
diff --git a/src/test/regress/sql/role_lastupdated.sql b/src/test/regress/sql/role_lastupdated.sql
new file mode 100644
index 00000000000..a8c4dbcb71c
--- /dev/null
+++ b/src/test/regress/sql/role_lastupdated.sql
@@ -0,0 +1,53 @@
+--
+-- Tests for pg_authid.rollastupdated
+--
+-- rollastupdated records when the most recent CREATE ROLE or ALTER ROLE
+-- command for a role was executed, so that tools managing roles
+-- declaratively can cheaply detect that a role may have changed.
+--
+-- Each command below is checked twice against the timestamp recorded
+-- before it ran: first while nothing has happened yet, where no row
+-- should qualify, and again afterwards, where the role should appear.
+--
+
+-- roles created during initdb have no recorded modification time
+SELECT rollastupdated IS NULL AS bootstrap_role_is_null
+    FROM pg_authid WHERE rolname = 'pg_read_all_data';
+
+-- CREATE ROLE records a timestamp
+CREATE ROLE regress_lastupdated_role;
+SELECT rollastupdated IS NOT NULL AS create_sets_lastupdated
+    FROM pg_authid WHERE rolname = 'regress_lastupdated_role';
+
+SELECT rollastupdated AS ts_created
+    FROM pg_authid WHERE rolname = 'regress_lastupdated_role' \gset
+
+-- changing an attribute advances it
+SELECT rolname FROM pg_authid
+    WHERE rolname = 'regress_lastupdated_role' AND rollastupdated > :'ts_created';
+ALTER ROLE regress_lastupdated_role WITH CONNECTION LIMIT 5;
+SELECT rolname FROM pg_authid
+    WHERE rolname = 'regress_lastupdated_role' AND rollastupdated > :'ts_created';
+
+SELECT rollastupdated AS ts_altered
+    FROM pg_authid WHERE rolname = 'regress_lastupdated_role' \gset
+
+-- ALTER ROLE ... SET stores the setting outside pg_authid, but still
+-- advances it
+SELECT rolname FROM pg_authid
+    WHERE rolname = 'regress_lastupdated_role' AND rollastupdated > :'ts_altered';
+ALTER ROLE regress_lastupdated_role SET work_mem = '10MB';
+SELECT rolname FROM pg_authid
+    WHERE rolname = 'regress_lastupdated_role' AND rollastupdated > :'ts_altered';
+
+SELECT rollastupdated AS ts_set
+    FROM pg_authid WHERE rolname = 'regress_lastupdated_role' \gset
+
+-- a rename advances it too, and the value is visible through pg_roles
+SELECT rolname FROM pg_roles
+    WHERE rolname = 'regress_lastupdated_role' AND rollastupdated > :'ts_set';
+ALTER ROLE regress_lastupdated_role RENAME TO regress_lastupdated_role2;
+SELECT rolname FROM pg_roles
+    WHERE rolname = 'regress_lastupdated_role2' AND rollastupdated > :'ts_set';
+
+DROP ROLE regress_lastupdated_role2;
-- 
2.55.0

