Hi Zsolt,
Thanks a lot for your review comments.
>postgres.c:1076: elsewhere password_valid_until_timestamp is set to 0
>when NULL, won't that result in unintended disconnection for users?
Done. I have modified the condition check so as it will not impact users
having rolvaliduntil to NULL.
>postgres.c:99: it only checks the expiration in exec_simple_query,
>shouldn't it also be part of other methods (like
>exec_execute_message)?
I missed it. In the attached patch validation is now performed across all
primary query execution entry points to cover both simple and Extended
query protocols.
>postgres.c:179: isn't the sys_cache_register_callback variable name a
>bit too generic, shouldn't it have a more specific name related to
>password expiration / authentication?
I agree. I have renamed the variable to a more appropriate name
password_auth_cache_callback_registered.
> postgres.c:1082: the errhint text should have a period at the end.
Done.
>postgres.c:4185: The comment for CheckPasswordExpiration says that the
>function terminates the connection with FATAL, but the termination is
>actually at the call site at line 1077. Maybe it would be better to
>move that if/error inside the function, as the comment explains?
Done.
I have attached an updated patch. Request a review.
Thanks & Best Regards,
Ajit
On Tue, 20 Jan 2026 at 14:59, Zsolt Parragi <[email protected]>
wrote:
> Hello!
>
> I noticed a few things in the patch, please consider the following:
>
> postgres.c:1076: elsewhere password_valid_until_timestamp is set to 0
> when NULL, won't that result in unintended disconnection for users?
>
> postgres.c:99: it only checks the expiration in exec_simple_query,
> shouldn't it also be part of other methods (like
> exec_execute_message)?
>
> postgres.c:179: isn't the sys_cache_register_callback variable name a
> bit too generic, shouldn't it have a more specific name related to
> password expiration / authentication?
>
> postgres.c:1082: the errhint text should have a period at the end.
>
> postgres.c:4185: The comment for CheckPasswordExpiration says that the
> function terminates the connection with FATAL, but the termination is
> actually at the call site at line 1077. Maybe it would be better to
> move that if/error inside the function, as the comment explains?
>
diff --git a/src/backend/libpq/crypt.c b/src/backend/libpq/crypt.c
index 4c1052b3d42..eafc4309748 100644
--- a/src/backend/libpq/crypt.c
+++ b/src/backend/libpq/crypt.c
@@ -20,6 +20,7 @@
#include "common/scram-common.h"
#include "libpq/crypt.h"
#include "libpq/scram.h"
+#include "miscadmin.h"
#include "utils/builtins.h"
#include "utils/syscache.h"
#include "utils/timestamp.h"
@@ -66,7 +67,19 @@ get_role_password(const char *role, const char **logdetail)
datum = SysCacheGetAttr(AUTHNAME, roleTup,
Anum_pg_authid_rolvaliduntil, &isnull);
if (!isnull)
+ {
vuntil = DatumGetTimestampTz(datum);
+ /*
+ * Cache the password expiration timestamp from pg_authid.rolvaliduntil
+ * during initial authentication so it can be checked throughout the
+ * lifetime of the connection. By changing this value from -1 to >= 0
+ * we signal that password authentication was used.
+ */
+ password_valid_until_timestamp = vuntil;
+ }
+ /* No expiration limit set */
+ else
+ password_valid_until_timestamp = 0;
ReleaseSysCache(roleTup);
diff --git a/src/backend/tcop/postgres.c b/src/backend/tcop/postgres.c
index e54bf1e760f..6a62cfc50bd 100644
--- a/src/backend/tcop/postgres.c
+++ b/src/backend/tcop/postgres.c
@@ -34,6 +34,7 @@
#include "access/parallel.h"
#include "access/printtup.h"
#include "access/xact.h"
+#include "catalog/pg_authid.h"
#include "catalog/pg_type.h"
#include "commands/async.h"
#include "commands/event_trigger.h"
@@ -74,10 +75,12 @@
#include "tcop/utility.h"
#include "utils/guc_hooks.h"
#include "utils/injection_point.h"
+#include "utils/inval.h"
#include "utils/lsyscache.h"
#include "utils/memutils.h"
#include "utils/ps_status.h"
#include "utils/snapmgr.h"
+#include "utils/syscache.h"
#include "utils/timeout.h"
#include "utils/timestamp.h"
#include "utils/varlena.h"
@@ -105,6 +108,13 @@ int client_connection_check_interval = 0;
/* flags for non-system relation kinds to restrict use */
int restrict_nonsystem_relation_kind;
+/*
+ * Flag set by syscache listener to indicate if the user's password validity
+ * (rolvaliduntil) needs to be checked for expiration before the next
+ * command execution.
+ */
+static bool AuthCheckNeeded = false;
+
/* ----------------
* private typedefs etc
* ----------------
@@ -163,6 +173,13 @@ static volatile sig_atomic_t RecoveryConflictPendingReasons[NUM_PROCSIGNALS];
static MemoryContext row_description_context = NULL;
static StringInfoData row_description_buf;
+/*
+ * Tracks whether the SysCache callback for AUTHOID has been registered.
+ * This ensures CacheRegisterSyscacheCallback is called exactly once during
+ * backend initialization, preventing redundant registrations in the main loop.
+ */
+static bool password_auth_cache_callback_registered = false;
+
/* ----------------------------------------------------------------
* decls for routines only used in this file
* ----------------------------------------------------------------
@@ -186,6 +203,8 @@ static void drop_unnamed_stmt(void);
static void log_disconnections(int code, Datum arg);
static void enable_statement_timeout(void);
static void disable_statement_timeout(void);
+static void AuthCacheInvalidated(Datum arg, int cacheid, uint32 hashvalue);
+static void enforce_password_expiration(void);
/* ----------------------------------------------------------------
@@ -1050,6 +1069,11 @@ exec_simple_query(const char *query_string)
*/
start_xact_command();
+ /*
+ * Verify that the user's password has not expired.
+ */
+ enforce_password_expiration();
+
/*
* Zap any pre-existing unnamed statement. (While not strictly necessary,
* it seems best to define simple-Query mode as if it used the unnamed
@@ -1432,6 +1456,11 @@ exec_parse_message(const char *query_string, /* string to execute */
*/
start_xact_command();
+ /*
+ * Verify that the user's password has not expired.
+ */
+ enforce_password_expiration();
+
/*
* Switch to appropriate context for constructing parsetrees.
*
@@ -1708,6 +1737,11 @@ exec_bind_message(StringInfo input_message)
*/
start_xact_command();
+ /*
+ * Verify that the user's password has not expired.
+ */
+ enforce_password_expiration();
+
/* Switch back to message context */
MemoryContextSwitchTo(MessageContext);
@@ -2221,6 +2255,11 @@ exec_execute_message(const char *portal_name, long max_rows)
*/
start_xact_command();
+ /*
+ * Verify that the user's password has not expired.
+ */
+ enforce_password_expiration();
+
/*
* If we re-issue an Execute protocol request against an existing portal,
* then we are only fetching more rows rather than completely re-executing
@@ -2654,6 +2693,11 @@ exec_describe_statement_message(const char *stmt_name)
*/
start_xact_command();
+ /*
+ * Verify that the user's password has not expired.
+ */
+ enforce_password_expiration();
+
/* Switch back to message context */
MemoryContextSwitchTo(MessageContext);
@@ -2747,6 +2791,11 @@ exec_describe_portal_message(const char *portal_name)
*/
start_xact_command();
+ /*
+ * Verify that the user's password has not expired.
+ */
+ enforce_password_expiration();
+
/* Switch back to message context */
MemoryContextSwitchTo(MessageContext);
@@ -4518,6 +4567,20 @@ PostgresMain(const char *dbname, const char *username)
if (!ignore_till_sync)
send_ready_for_query = true; /* initially, or after error */
+
+ /*
+ * Register a SysCache listener for pg_authid changes (specifically for
+ * rolvaliduntil). This provides an event-driven mechanism to enforce
+ * password/authorization expiration immediately upon change, rather than
+ * relying on polling. The callback sets a flag (AuthCheckNeeded) which
+ * is checked before executing each simple query.
+ */
+ if (!password_auth_cache_callback_registered)
+ {
+ CacheRegisterSyscacheCallback(AUTHOID, AuthCacheInvalidated, (Datum) 0);
+ password_auth_cache_callback_registered = true;
+ }
+
/*
* Non-error queries loop here.
*/
@@ -5237,3 +5300,92 @@ disable_statement_timeout(void)
if (get_timeout_active(STATEMENT_TIMEOUT))
disable_timeout(STATEMENT_TIMEOUT, false);
}
+
+/*
+ * CheckPasswordExpiration
+ * Refreshes the cached password expiration timestamp from the system cache.
+ * This function looks up the current user's entry in pg_authid and updates
+ * 'password_valid_until_timestamp' with the current value of 'rolvaliduntil'.
+ * It is called by enforce_password_expiration() when the 'AuthCheckNeeded'
+ * flag is set, typically due to a syscache invalidation (AuthCacheInvalidated).
+ */
+static void
+CheckPasswordExpiration(void)
+{
+ HeapTuple tuple;
+
+ /*
+ * Look up the current user's entry in pg_authid. We must do this, even
+ * if only AuthCheckNeeded is set, because GetUserId() might return a
+ * different user ID than the one that triggered the invalidation (though
+ * that's unlikely for AUTHOID).
+ */
+
+ tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(GetUserId()));
+
+ if (HeapTupleIsValid(tuple))
+ {
+ Datum rolvaliduntil_datum;
+ bool validUntil_null;
+
+ /* Get the expiration time column */
+ rolvaliduntil_datum = SysCacheGetAttr(AUTHNAME, tuple,
+ Anum_pg_authid_rolvaliduntil,
+ &validUntil_null);
+
+ if (!validUntil_null)
+ password_valid_until_timestamp = DatumGetTimestampTz(rolvaliduntil_datum);
+ else
+ password_valid_until_timestamp = 0;
+
+ ReleaseSysCache(tuple);
+ }
+ /* Reset the flag after performing the check */
+ AuthCheckNeeded = false;
+}
+
+/*
+ * enforce_password_expiration
+ *
+ * Check if the user's password has expired and terminate the connection
+ * if necessary. This encapsulates the state checks and the FATAL report.
+ * CheckPasswordExpiration must only be called when the system is out of
+ * recovery and inside a valid transaction.
+ */
+static void
+enforce_password_expiration(void)
+{
+
+ if (!RecoveryInProgress() && IsTransactionState() &&
+ password_valid_until_timestamp > 0)
+ {
+ if (AuthCheckNeeded)
+ CheckPasswordExpiration();
+
+ if (password_valid_until_timestamp < GetCurrentTransactionStartTimestamp())
+ ereport(FATAL,
+ (errcode(ERRCODE_INVALID_AUTHORIZATION_SPECIFICATION),
+ errmsg("Connection expired due to internal password policy enforcement"),
+ errdetail("User's password expired at %s.",
+ timestamptz_to_str(password_valid_until_timestamp)),
+ errhint("Reconnect with a renewed password.")));
+ }
+}
+
+/*
+ * AuthCacheInvalidated
+ * Syscache callback function registered for the AUTHOID cache (pg_authid).
+ *
+ * This function is executed whenever a tuple in pg_authid is updated, inserted,
+ * or deleted. Its primary purpose is to catch changes to the currently
+ * connected user's 'rolvaliduntil' field.
+ *
+ * It sets the static flag AuthCheckNeeded to true, signaling that the user's
+ * password expiration status must be checked.
+ */
+static void
+AuthCacheInvalidated(Datum arg, int cacheid, uint32 hashvalue)
+{
+ /* This callback is executed when an entry in pg_authid changes */
+ AuthCheckNeeded = true;
+}
diff --git a/src/backend/utils/init/globals.c b/src/backend/utils/init/globals.c
index 36ad708b360..069138ec1d5 100644
--- a/src/backend/utils/init/globals.c
+++ b/src/backend/utils/init/globals.c
@@ -165,3 +165,10 @@ int notify_buffers = 16;
int serializable_buffers = 32;
int subtransaction_buffers = 0;
int transaction_buffers = 0;
+
+/*
+ * Cached value of the current user's password expiration time (pg_authid.rolvaliduntil).
+ * This value is updated via CheckPasswordExpiration() when the AuthCheckNeeded
+ * flag is set by a syscache invalidation callback.
+ */
+TimestampTz password_valid_until_timestamp = -1;
diff --git a/src/include/miscadmin.h b/src/include/miscadmin.h
index db559b39c4d..146c3769b2f 100644
--- a/src/include/miscadmin.h
+++ b/src/include/miscadmin.h
@@ -287,7 +287,7 @@ extern PGDLLIMPORT double VacuumCostDelay;
extern PGDLLIMPORT int VacuumCostBalance;
extern PGDLLIMPORT bool VacuumCostActive;
-
+extern PGDLLIMPORT TimestampTz password_valid_until_timestamp;
/* in utils/misc/stack_depth.c */