From f85b8a1ffbc839c0c4d0becfeb72c1120a2493aa Mon Sep 17 00:00:00 2001 From: Ajit Awekar Date: Tue, 29 Sep 2026 14:57:56 +0530 Subject: [PATCH v3 1/2] Add continuous credential validation framework A session's credentials are currently only checked once, at authentication. If a role is dropped or expires afterward, an already-connected session keeps working indefinitely. Add a per-backend timer that periodically re-validates a session's credentials and terminates it with FATAL if they are no longer valid, checked at command boundaries so a running statement is never interrupted mid-execution. Two GUCs control this: credential_validation_enabled (default off) and credential_validation_interval (5..3600s, default 60). This commit adds the framework and validator registry, plus the baseline, auth-method-independent check: the authenticated role must still exist and not have passed its rolvaliduntil expiration. Method-specific validators plug in via RegisterCredentialValidator() and are added in following commits. --- doc/src/sgml/config.sgml | 60 ++ src/backend/libpq/Makefile | 2 + src/backend/libpq/auth-validate-methods.c | 81 +++ src/backend/libpq/auth-validate.c | 288 ++++++++++ src/backend/libpq/meson.build | 2 + src/backend/replication/walsender.c | 35 ++ src/backend/tcop/postgres.c | 47 ++ src/backend/utils/init/globals.c | 1 + src/backend/utils/init/postinit.c | 18 + src/backend/utils/misc/guc_parameters.dat | 16 + src/backend/utils/misc/guc_tables.c | 1 + src/backend/utils/misc/postgresql.conf.sample | 6 + src/include/libpq/auth-validate-methods.h | 28 + src/include/libpq/auth-validate.h | 67 +++ src/include/miscadmin.h | 1 + src/include/utils/timeout.h | 1 + src/test/authentication/meson.build | 1 + .../t/008_continuous_validation.pl | 544 ++++++++++++++++++ 18 files changed, 1199 insertions(+) create mode 100644 src/backend/libpq/auth-validate-methods.c create mode 100644 src/backend/libpq/auth-validate.c create mode 100644 src/include/libpq/auth-validate-methods.h create mode 100644 src/include/libpq/auth-validate.h create mode 100755 src/test/authentication/t/008_continuous_validation.pl diff --git a/doc/src/sgml/config.sgml b/doc/src/sgml/config.sgml index f36fbb60101..6c2e5c90be1 100644 --- a/doc/src/sgml/config.sgml +++ b/doc/src/sgml/config.sgml @@ -1124,6 +1124,66 @@ include_dir 'conf.d' + + credential_validation_enabled (boolean) + + credential_validation_enabled configuration parameter + + + + + + When enabled, each backend periodically re-validates the credentials of + its active session and terminates the session if they are no longer + valid. The baseline check verifies that the authenticated role still + exists and has not passed its VALID UNTIL expiration; + depending on the authentication method, an additional method-specific + check is applied, such as expiration of an OAuth + bearer token or of the client certificate. The default is + off. + + + The re-validation period is controlled by + . Validation is + performed at command boundaries, so a session is never interrupted in + the middle of a running statement. + + + Only superusers and users with the appropriate SET + privilege can change this parameter at session start, and it cannot be + changed at all within a session. This is deliberate: since the whole + point of this parameter is to catch a session whose credentials + became invalid after authentication, it must not + be possible for an already-connected session — including one + belonging to a superuser whose own credentials are being revoked + — to disable the check on itself and evade that revocation. + + + + + + credential_validation_interval (integer) + + credential_validation_interval configuration parameter + + + + + + Sets the interval between the periodic credential re-validations that are + performed when is + enabled. If this value is specified without units, it is taken as + seconds. The valid range is from 5 seconds to 3600 seconds (one hour), + and the default is 60 seconds. + + + Like , this + parameter can only be set at session start and cannot be changed + within a session. + + + + password_encryption (enum) diff --git a/src/backend/libpq/Makefile b/src/backend/libpq/Makefile index 98eb2a8242d..32e4c7280e5 100644 --- a/src/backend/libpq/Makefile +++ b/src/backend/libpq/Makefile @@ -18,6 +18,8 @@ OBJS = \ auth-oauth.o \ auth-sasl.o \ auth-scram.o \ + auth-validate-methods.o \ + auth-validate.o \ auth.o \ be-fsstubs.o \ be-secure-common.o \ diff --git a/src/backend/libpq/auth-validate-methods.c b/src/backend/libpq/auth-validate-methods.c new file mode 100644 index 00000000000..1db1896af0e --- /dev/null +++ b/src/backend/libpq/auth-validate-methods.c @@ -0,0 +1,81 @@ +/*------------------------------------------------------------------------- + * + * auth-validate-methods.c + * Implementation of authentication credential validation methods + * + * This module implements the credential validators: the baseline + * role-level check here, plus method-specific ones registered via + * RegisterCredentialValidator(). + * + * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * + * IDENTIFICATION + * src/backend/libpq/auth-validate-methods.c + * + *------------------------------------------------------------------------- + */ +#include "postgres.h" + +#include "access/htup_details.h" +#include "catalog/pg_authid.h" +#include "libpq/auth-validate-methods.h" +#include "miscadmin.h" +#include "utils/syscache.h" +#include "utils/timestamp.h" + +/* + * Initialize validation methods + */ +void +InitializeValidationMethods(void) +{ + /* No method-specific validators are registered yet. */ +} + +/* + * Baseline check for every session: role must still exist and not have + * passed rolvaliduntil. Uses GetAuthenticatedUserId(), not + * GetSessionUserId(), so SET SESSION AUTHORIZATION can't mask expiry. + */ +bool +ValidateRoleValidity(void) +{ + HeapTuple tuple; + Datum datum; + bool isnull; + TimestampTz valid_until; + bool result; + + tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(GetAuthenticatedUserId())); + + if (!HeapTupleIsValid(tuple)) + { + /* + * The role is gone, so we have no catalog row to name it from; fall + * back to the username the client originally authenticated as. + */ + SetCredentialValidationFailureDetail("role validity check failed for user \"%s\": role no longer exists", + MyProcPort ? MyProcPort->user_name : "?"); + return false; /* role no longer exists */ + } + + datum = SysCacheGetAttr(AUTHOID, tuple, + Anum_pg_authid_rolvaliduntil, + &isnull); + if (!isnull) + { + valid_until = DatumGetTimestampTz(datum); + result = (valid_until >= GetCurrentTimestamp()); + } + else + result = true; /* no expiration set */ + + if (!result) + SetCredentialValidationFailureDetail("role validity check failed for user \"%s\": role has passed its VALID UNTIL expiration (%s)", + NameStr(((Form_pg_authid) GETSTRUCT(tuple))->rolname), + timestamptz_to_str(valid_until)); + + ReleaseSysCache(tuple); + return result; +} diff --git a/src/backend/libpq/auth-validate.c b/src/backend/libpq/auth-validate.c new file mode 100644 index 00000000000..1d6bd5eef0b --- /dev/null +++ b/src/backend/libpq/auth-validate.c @@ -0,0 +1,288 @@ +/*------------------------------------------------------------------------- + * + * auth-validate.c + * Implementation of authentication credential validation + * + * This module provides a mechanism for validating credentials during + * an active PostgreSQL session. + * + * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * + * IDENTIFICATION + * src/backend/libpq/auth-validate.c + * + *------------------------------------------------------------------------- + */ +#include "postgres.h" + +#include "access/xact.h" +#include "libpq/auth-validate-methods.h" +#include "libpq/auth-validate.h" +#include "libpq/auth.h" +#include "libpq/libpq-be.h" +#include "miscadmin.h" +#include "postmaster/postmaster.h" +#include "replication/walsender.h" +#include "storage/ipc.h" +#include "utils/timeout.h" +#include "utils/timestamp.h" + +/* GUC variables */ +bool credential_validation_enabled; +int credential_validation_interval; + + +/* Registered credential validators */ +static CredentialValidationCallback validators[CVT_COUNT]; + +/* + * Why the last validation check failed (log only, never sent to the + * client). Set via SetCredentialValidationFailureDetail(), reset each + * cycle, read by ProcessCredentialValidation(). + */ +#define CREDENTIAL_VALIDATION_DETAIL_LEN 256 +static char credential_validation_detail[CREDENTIAL_VALIDATION_DETAIL_LEN]; + +void +SetCredentialValidationFailureDetail(const char *fmt,...) +{ + va_list args; + + va_start(args, fmt); + vsnprintf(credential_validation_detail, sizeof(credential_validation_detail), fmt, args); + va_end(args); +} + +/* + * Deadline for the next cycle, tracked separately so error recovery can + * re-arm at this deadline (not a fresh interval) and not be gamed by + * repeated errors. + */ +static TimestampTz next_validation_deadline = 0; + + +/* + * Convert UserAuth enum to CredentialValidationType for validator selection + */ +static CredentialValidationType +UserAuthToValidationType(UserAuth auth_method) +{ + switch (auth_method) + { + case uaOAuth: + return CVT_OAUTH; + case uaCert: + return CVT_CERT; + default: + /* + * No validator for password/md5/scram; their only check is + * the baseline one (ValidateRoleValidity()). + */ + return CVT_COUNT; /* Invalid value */ + } +} + +/* + * Runs a full validity check when a validation cycle is due; FATALs the + * session if credentials have expired. + */ +void +ProcessCredentialValidation(void) +{ + bool valid; + bool own_xact = false; + + if (ClientAuthInProgress || IsInitProcessingMode() || IsBootstrapProcessingMode()) + return; + + if (!credential_validation_enabled || MyClientConnectionInfo.authn_id == NULL) + return; + + /* + * Validators read the catalogs, which needs a live transaction; skip + * and retry next interval if we're in an aborted transaction block. + */ + if (IsAbortedTransactionBlockState()) + return; + + /* + * Start a short-lived transaction if none is open; reuse (but don't + * commit) an existing one, so validation still runs at each command + * boundary inside a transaction block. + */ + if (!IsTransactionState()) + { + StartTransactionCommand(); + own_xact = true; + } + + valid = CheckCredentialValidity(); + + if (own_xact) + CommitTransactionCommand(); + + if (!valid) + ereport(FATAL, + (errcode(ERRCODE_INVALID_AUTHORIZATION_SPECIFICATION), + errmsg("session credentials have expired"), + errdetail_log("%s", credential_validation_detail), + errhint("Please reconnect to establish a new authenticated session."))); +} + +/* + * Called from InitPostgres() after authentication; registers all + * method-specific validators. + */ +void +InitializeCredentialValidation(void) +{ + int i; + + /* Initialize validator callbacks to NULL */ + for (i = 0; i < CVT_COUNT; i++) + validators[i] = NULL; + + /* Register all method-specific validation callbacks */ + InitializeValidationMethods(); +} + +/* + * Arms the validation timer with a fresh interval and sets the next + * deadline. Called at session start and after each validation cycle. + */ +void +EnableCredentialValidationTimeout(void) +{ + int interval_ms; + + /* Only enable if credential validation is configured */ + if (!credential_validation_enabled) + return; + + /* Skip for non-client backends */ + if (!IsExternalConnectionBackend(MyBackendType)) + return; + + /* + * Physical walsenders never run SQL and never revisit the dispatch + * loop, so arming this would be a no-op; logical (db-connected) ones + * do run SQL and are serviced via WalSndHandleCredentialValidation(). + */ + if (AmWalSenderProcess() && !am_db_walsender) + return; + + /* Convert interval from seconds to milliseconds */ + interval_ms = credential_validation_interval * 1000; + + next_validation_deadline = TimestampTzPlusMilliseconds(GetCurrentTimestamp(), interval_ms); + + enable_timeout_after(CREDENTIAL_VALIDATION_TIMEOUT, interval_ms); + + elog(DEBUG1, "credential validation timeout enabled, interval=%d s", credential_validation_interval); +} + +/* + * Re-arms the timer after error recovery cancels it, at the previously + * established deadline (not a fresh interval) -- an already-elapsed + * deadline fires almost immediately, so repeated errors can't delay this. + */ +void +RearmCredentialValidationTimeout(void) +{ + if (!credential_validation_enabled) + return; + + if (!IsExternalConnectionBackend(MyBackendType)) + return; + + /* See the matching check in EnableCredentialValidationTimeout(). */ + if (AmWalSenderProcess() && !am_db_walsender) + return; + + /* No deadline established yet (shouldn't normally happen); start over. */ + if (next_validation_deadline == 0) + { + EnableCredentialValidationTimeout(); + return; + } + + enable_timeout_at(CREDENTIAL_VALIDATION_TIMEOUT, next_validation_deadline); +} + +/* + * Register a validator callback for a specific authentication method + */ +void +RegisterCredentialValidator(CredentialValidationType method_type, CredentialValidationCallback validator) +{ + if (method_type < 0 || method_type >= CVT_COUNT) + ereport(ERROR, + (errcode(ERRCODE_INVALID_PARAMETER_VALUE), + errmsg("invalid validation method type: %d", method_type))); + + validators[method_type] = validator; +} + +/* + * Returns true if the session's credentials are still valid. Must be + * called within a transaction, since validators read the catalogs. + */ +bool +CheckCredentialValidity(void) +{ + CredentialValidationCallback validator = NULL; + CredentialValidationType validation_type; + bool result; + + /* + * Nothing to validate during shutdown, for non-client backends, for + * physical walsenders (out of scope; logical ones are handled), or + * mid-authentication. Hot standby sessions are NOT skipped. + */ + if (proc_exit_inprogress || + !IsExternalConnectionBackend(MyBackendType) || + (AmWalSenderProcess() && !am_db_walsender) || + AmAutoVacuumLauncherProcess() || + AmAutoVacuumWorkerProcess() || + AmBackgroundWorkerProcess() || + ClientAuthInProgress) + return true; + + /* Without an authenticated session there is nothing to validate. */ + if (MyClientConnectionInfo.authn_id == NULL) + return true; + + elog(DEBUG1, "credential validation: checking auth_method=%d", + (int) MyClientConnectionInfo.auth_method); + + /* Discard any leftover detail from a previous, unrelated check. */ + credential_validation_detail[0] = '\0'; + + /* + * Role-level validity (rolvaliduntil / role existence) is a baseline that + * applies to every authenticated session, regardless of auth method. + */ + result = ValidateRoleValidity(); + + /* + * Additionally run the method-specific validator if one is registered for + * this auth method (e.g. OAuth token expiry, client certificate expiry). + */ + validation_type = UserAuthToValidationType(MyClientConnectionInfo.auth_method); + if (validation_type < CVT_COUNT) + validator = validators[validation_type]; + + if (result && validator != NULL) + { + result = validator(); + + /* Fallback in case a validator forgot to set its own detail. */ + if (!result && credential_validation_detail[0] == '\0') + SetCredentialValidationFailureDetail("method-specific credential check failed for user \"%s\" (auth method %d)", + MyProcPort ? MyProcPort->user_name : "?", + (int) MyClientConnectionInfo.auth_method); + } + + return result; +} diff --git a/src/backend/libpq/meson.build b/src/backend/libpq/meson.build index 8571f652844..2e69685672b 100644 --- a/src/backend/libpq/meson.build +++ b/src/backend/libpq/meson.build @@ -4,6 +4,8 @@ backend_sources += files( 'auth-oauth.c', 'auth-sasl.c', 'auth-scram.c', + 'auth-validate-methods.c', + 'auth-validate.c', 'auth.c', 'be-fsstubs.c', 'be-secure-common.c', diff --git a/src/backend/replication/walsender.c b/src/backend/replication/walsender.c index e9331de3df5..8d12590dfb5 100644 --- a/src/backend/replication/walsender.c +++ b/src/backend/replication/walsender.c @@ -65,6 +65,7 @@ #include "catalog/pg_type.h" #include "commands/defrem.h" #include "funcapi.h" +#include "libpq/auth-validate.h" #include "libpq/libpq.h" #include "libpq/pqformat.h" #include "libpq/protocol.h" @@ -1712,6 +1713,31 @@ WalSndHandleConfigReload(void) SyncRepReleaseWaiters(); } +/* + * Services a pending credential re-validation cycle for a logical + * walsender, mirroring WalSndHandleConfigReload() -- once streaming + * starts, PostgresMain()'s dispatch loop is never revisited otherwise. + */ +static void +WalSndHandleCredentialValidation(void) +{ + if (!am_db_walsender) + return; + + if (!CredentialValidationTimeoutPending) + return; + + CredentialValidationTimeoutPending = false; + ProcessCredentialValidation(); /* may FATAL if credentials expired */ + + /* + * Re-arm for the next cycle (fires once per arming), mirroring + * postgres.c's two call sites -- else this would validate exactly + * once and never again for the rest of the stream. + */ + EnableCredentialValidationTimeout(); +} + /* * Wait until there is no pending write. Also process replies from the other * side and check timeouts during that. @@ -1756,6 +1782,9 @@ ProcessPendingWrites(void) /* Process any requests or signals received recently */ WalSndHandleConfigReload(); + /* Terminate the session if mandatory credential re-validation is due */ + WalSndHandleCredentialValidation(); + /* Try to flush pending output to the client */ if (pq_flush_if_writable() != 0) WalSndShutdown(); @@ -1959,6 +1988,9 @@ WalSndWaitForWal(XLogRecPtr loc) /* Process any requests or signals received recently */ WalSndHandleConfigReload(); + /* Terminate the session if mandatory credential re-validation is due */ + WalSndHandleCredentialValidation(); + /* Check for input from the client */ ProcessRepliesIfAny(); @@ -3093,6 +3125,9 @@ WalSndLoop(WalSndSendDataCallback send_data) /* Process any requests or signals received recently */ WalSndHandleConfigReload(); + /* Terminate the session if mandatory credential re-validation is due */ + WalSndHandleCredentialValidation(); + /* Check for input from the client */ ProcessRepliesIfAny(); diff --git a/src/backend/tcop/postgres.c b/src/backend/tcop/postgres.c index b6bdfe213fe..ce6e71c93c3 100644 --- a/src/backend/tcop/postgres.c +++ b/src/backend/tcop/postgres.c @@ -42,6 +42,7 @@ #include "commands/repack.h" #include "common/pg_prng.h" #include "jit/jit.h" +#include "libpq/auth-validate.h" #include "libpq/libpq.h" #include "libpq/pqformat.h" #include "libpq/pqsignal.h" @@ -528,6 +529,24 @@ ProcessClientReadInterrupt(bool blocked) /* Process notify interrupts, if any */ if (notifyInterruptPending) ProcessNotifyInterrupt(true); + + /* + * Run a pending validation cycle here too (idle sessions would + * otherwise go unterminated until their next command). Service a + * pending config reload first, since some validators consult GUCs. + */ + if (ConfigReloadPending) + { + ConfigReloadPending = false; + ProcessConfigFile(PGC_SIGHUP); + } + + if (CredentialValidationTimeoutPending && IsNormalProcessingMode()) + { + CredentialValidationTimeoutPending = false; + ProcessCredentialValidation(); /* may FATAL if credentials expired */ + EnableCredentialValidationTimeout(); + } } else if (ProcDiePending) { @@ -4771,6 +4790,14 @@ PostgresMain(const char *dbname, const char *username) enable_timeout_after(IDLE_IN_TRANSACTION_SESSION_TIMEOUT, IdleInTransactionSessionTimeout); } + + /* + * Re-arm if cancelled by error recovery (no fresh + * interval); see RearmCredentialValidationTimeout(). + */ + if (credential_validation_enabled && + !get_timeout_active(CREDENTIAL_VALIDATION_TIMEOUT)) + RearmCredentialValidationTimeout(); } else { @@ -4823,6 +4850,14 @@ PostgresMain(const char *dbname, const char *username) enable_timeout_after(IDLE_SESSION_TIMEOUT, IdleSessionTimeout); } + + /* + * Re-arm if cancelled by error recovery (no fresh + * interval); see RearmCredentialValidationTimeout(). + */ + if (credential_validation_enabled && + !get_timeout_active(CREDENTIAL_VALIDATION_TIMEOUT)) + RearmCredentialValidationTimeout(); } /* Report any recently-changed GUC options */ @@ -4925,6 +4960,18 @@ PostgresMain(const char *dbname, const char *username) if (ignore_till_sync && firstchar != EOF) continue; + /* + * Run a due validation cycle here too -- a fallback for when + * ReadCommand() never blocked (so ProcessClientReadInterrupt()'s + * check point above never ran), e.g. a buffered pipelined message. + */ + if (CredentialValidationTimeoutPending && IsNormalProcessingMode()) + { + CredentialValidationTimeoutPending = false; + ProcessCredentialValidation(); /* may FATAL if credentials expired */ + EnableCredentialValidationTimeout(); + } + switch (firstchar) { case PqMsg_Query: diff --git a/src/backend/utils/init/globals.c b/src/backend/utils/init/globals.c index bbd28d14d99..5b9df8fd3f1 100644 --- a/src/backend/utils/init/globals.c +++ b/src/backend/utils/init/globals.c @@ -34,6 +34,7 @@ volatile sig_atomic_t QueryCancelPending = false; volatile sig_atomic_t ProcDiePending = false; volatile sig_atomic_t CheckClientConnectionPending = false; volatile sig_atomic_t ClientConnectionLost = false; +volatile sig_atomic_t CredentialValidationTimeoutPending = false; volatile sig_atomic_t IdleInTransactionSessionTimeoutPending = false; volatile sig_atomic_t TransactionTimeoutPending = false; volatile sig_atomic_t IdleSessionTimeoutPending = false; diff --git a/src/backend/utils/init/postinit.c b/src/backend/utils/init/postinit.c index 8b6ea195eca..959b1482e67 100644 --- a/src/backend/utils/init/postinit.c +++ b/src/backend/utils/init/postinit.c @@ -33,6 +33,7 @@ #include "catalog/pg_database.h" #include "catalog/pg_db_role_setting.h" #include "catalog/pg_tablespace.h" +#include "libpq/auth-validate.h" #include "libpq/auth.h" #include "libpq/libpq-be.h" #include "mb/pg_wchar.h" @@ -96,6 +97,7 @@ static void TransactionTimeoutHandler(void); static void IdleSessionTimeoutHandler(void); static void IdleStatsUpdateTimeoutHandler(void); static void ClientCheckTimeoutHandler(void); +static void CredentialValidationTimeoutHandler(void); static bool ThereIsAtLeastOneRole(void); static void process_startup_options(Port *port, bool am_superuser); static void process_settings(Oid databaseid, Oid roleid); @@ -806,6 +808,8 @@ InitPostgres(const char *in_dbname, Oid dboid, RegisterTimeout(CLIENT_CONNECTION_CHECK_TIMEOUT, ClientCheckTimeoutHandler); RegisterTimeout(IDLE_STATS_UPDATE_TIMEOUT, IdleStatsUpdateTimeoutHandler); + RegisterTimeout(CREDENTIAL_VALIDATION_TIMEOUT, + CredentialValidationTimeoutHandler); } /* @@ -1269,6 +1273,12 @@ InitPostgres(const char *in_dbname, Oid dboid, /* Initialize this backend's session state. */ InitializeSession(); + /* Initialize credential validation system */ + InitializeCredentialValidation(); + + /* Enable credential validation timeout if configured */ + EnableCredentialValidationTimeout(); + /* * If this is an interactive session, load any libraries that should be * preloaded at backend start. Since those are determined by GUCs, this @@ -1475,6 +1485,14 @@ IdleStatsUpdateTimeoutHandler(void) SetLatch(MyLatch); } +static void +CredentialValidationTimeoutHandler(void) +{ + CredentialValidationTimeoutPending = true; + InterruptPending = true; + SetLatch(MyLatch); +} + static void ClientCheckTimeoutHandler(void) { diff --git a/src/backend/utils/misc/guc_parameters.dat b/src/backend/utils/misc/guc_parameters.dat index c57441f7d98..095005c6543 100644 --- a/src/backend/utils/misc/guc_parameters.dat +++ b/src/backend/utils/misc/guc_parameters.dat @@ -569,6 +569,22 @@ assign_hook => 'assign_createrole_self_grant', }, +{ name => 'credential_validation_enabled', type => 'bool', context => 'PGC_SU_BACKEND', group => 'CONN_AUTH_AUTH', + short_desc => 'Enables periodic re-validation of session credentials.', + long_desc => 'When enabled, each backend periodically re-checks that the authenticated role has not expired and that any method-specific credential (OAuth token, client certificate) is still valid.', + variable => 'credential_validation_enabled', + boot_val => 'false', +}, + +{ name => 'credential_validation_interval', type => 'int', context => 'PGC_SU_BACKEND', group => 'CONN_AUTH_AUTH', + short_desc => 'Sets the interval in seconds between credential re-validation checks.', + flags => 'GUC_UNIT_S', + variable => 'credential_validation_interval', + boot_val => '60', + min => '5', + max => '3600', +}, + { name => 'cursor_tuple_fraction', type => 'real', context => 'PGC_USERSET', group => 'QUERY_TUNING_OTHER', short_desc => 'Sets the planner\'s estimate of the fraction of a cursor\'s rows that will be retrieved.', flags => 'GUC_EXPLAIN', diff --git a/src/backend/utils/misc/guc_tables.c b/src/backend/utils/misc/guc_tables.c index 342aaeef59a..fd782b8b5fe 100644 --- a/src/backend/utils/misc/guc_tables.c +++ b/src/backend/utils/misc/guc_tables.c @@ -51,6 +51,7 @@ #include "common/file_utils.h" #include "common/scram-common.h" #include "jit/jit.h" +#include "libpq/auth-validate.h" #include "libpq/auth.h" #include "libpq/libpq.h" #include "libpq/oauth.h" diff --git a/src/backend/utils/misc/postgresql.conf.sample b/src/backend/utils/misc/postgresql.conf.sample index e759f06b50f..9813a43b66e 100644 --- a/src/backend/utils/misc/postgresql.conf.sample +++ b/src/backend/utils/misc/postgresql.conf.sample @@ -929,6 +929,12 @@ #include_if_exists = '...' # include file only if it exists #include = '...' # include file +#------------------------------------------------------------------------------ +# CREDENTIAL VALIDATION +#------------------------------------------------------------------------------ + +#credential_validation_enabled = off # re-validate session credentials periodically +#credential_validation_interval = 60 # revalidation interval in seconds (5-3600) #------------------------------------------------------------------------------ # CUSTOMIZED OPTIONS diff --git a/src/include/libpq/auth-validate-methods.h b/src/include/libpq/auth-validate-methods.h new file mode 100644 index 00000000000..9a03583d6e1 --- /dev/null +++ b/src/include/libpq/auth-validate-methods.h @@ -0,0 +1,28 @@ +/*------------------------------------------------------------------------- + * + * auth-validate-methods.h + * Interface for authentication credential validation methods + * + * This file provides declarations for various credential validation methods + * used with the credential validation system. + * + * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * + * src/include/libpq/auth-validate-methods.h + * + *------------------------------------------------------------------------- + */ +#ifndef AUTH_VALIDATE_METHODS_H +#define AUTH_VALIDATE_METHODS_H + +/* Initialize all validation methods */ +extern void InitializeValidationMethods(void); + +/* + * Baseline role-level validity check (rolvaliduntil / role existence), + * applied to every authenticated session regardless of auth method. + */ +extern bool ValidateRoleValidity(void); + +#endif /* AUTH_VALIDATE_METHODS_H */ diff --git a/src/include/libpq/auth-validate.h b/src/include/libpq/auth-validate.h new file mode 100644 index 00000000000..5e9a64d8885 --- /dev/null +++ b/src/include/libpq/auth-validate.h @@ -0,0 +1,67 @@ +/*------------------------------------------------------------------------- + * + * auth-validate.h + * Interface for authentication credential validation + * + * This file provides a common interface for validating credentials + * during an active PostgreSQL session. + * + * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * + * src/include/libpq/auth-validate.h + * + *------------------------------------------------------------------------- + */ +#ifndef AUTH_VALIDATE_H +#define AUTH_VALIDATE_H + +/* + * Define credential validation method types as an enum. Enumerators use + * the "CVT_" prefix, short for CredentialValidationType. + */ +typedef enum CredentialValidationType +{ + CVT_OAUTH = 0, /* OAuth bearer token authentication */ + CVT_CERT, /* TLS client certificate authentication */ + CVT_COUNT /* Total number of credential validation types */ +} CredentialValidationType; + +/* Process credential validation */ +extern void ProcessCredentialValidation(void); + +/* GUC variables */ +extern PGDLLIMPORT bool credential_validation_enabled; +extern PGDLLIMPORT int credential_validation_interval; + +/* Common credential validation callback prototype */ +typedef bool (*CredentialValidationCallback) (void); + +/* Initialize credential validation system */ +extern void InitializeCredentialValidation(void); + +/* Register a validation callback for a specific authentication method */ +extern void RegisterCredentialValidator(CredentialValidationType method_type, + CredentialValidationCallback validator); + +/* + * Check credential validity for the current session. Returns true if the + * credentials are still valid, false if they have expired. Must be called + * within a transaction, since the validators read the system catalogs. + */ +extern bool CheckCredentialValidity(void); + +/* + * Records why validation failed (log only, never sent to client), for + * ProcessCredentialValidation() to include via errdetail_log(). Callable + * more than once per cycle; the most recent call wins. + */ +extern void SetCredentialValidationFailureDetail(const char *fmt,...) pg_attribute_printf(1, 2); + +/* Enable credential validation timeout timer with a fresh full interval */ +extern void EnableCredentialValidationTimeout(void); + +/* Re-arm the timer after error recovery without moving the deadline */ +extern void RearmCredentialValidationTimeout(void); + +#endif /* AUTH_VALIDATE_H */ diff --git a/src/include/miscadmin.h b/src/include/miscadmin.h index 8d6aacc4d5a..97e388cf68d 100644 --- a/src/include/miscadmin.h +++ b/src/include/miscadmin.h @@ -101,6 +101,7 @@ extern PGDLLIMPORT volatile sig_atomic_t IdleStatsUpdateTimeoutPending; extern PGDLLIMPORT volatile sig_atomic_t CheckClientConnectionPending; extern PGDLLIMPORT volatile sig_atomic_t ClientConnectionLost; +extern PGDLLIMPORT volatile sig_atomic_t CredentialValidationTimeoutPending; /* these are marked volatile because they are examined by signal handlers: */ extern PGDLLIMPORT volatile uint32 InterruptHoldoffCount; diff --git a/src/include/utils/timeout.h b/src/include/utils/timeout.h index 4bae392fbd3..06450a5d412 100644 --- a/src/include/utils/timeout.h +++ b/src/include/utils/timeout.h @@ -36,6 +36,7 @@ typedef enum TimeoutId IDLE_STATS_UPDATE_TIMEOUT, CLIENT_CONNECTION_CHECK_TIMEOUT, STARTUP_PROGRESS_TIMEOUT, + CREDENTIAL_VALIDATION_TIMEOUT, /* First user-definable timeout reason */ USER_TIMEOUT, /* Maximum number of timeout reasons */ diff --git a/src/test/authentication/meson.build b/src/test/authentication/meson.build index 282a5054e2c..bfb8350a3f8 100644 --- a/src/test/authentication/meson.build +++ b/src/test/authentication/meson.build @@ -16,6 +16,7 @@ tests += { 't/005_sspi.pl', 't/006_login_trigger.pl', 't/007_pre_auth.pl', + 't/008_continuous_validation.pl', ], }, } diff --git a/src/test/authentication/t/008_continuous_validation.pl b/src/test/authentication/t/008_continuous_validation.pl new file mode 100755 index 00000000000..8ff3606dd1b --- /dev/null +++ b/src/test/authentication/t/008_continuous_validation.pl @@ -0,0 +1,544 @@ +use strict; +use warnings; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +if (!$use_unix_sockets) +{ + plan skip_all => "authentication tests cannot run without Unix-domain sockets"; +} + +# Helper to reset pg_hba.conf with specific auth method for test users +sub reset_pg_hba +{ + my ($node, $hba_method, @users) = @_; + + unlink($node->data_dir . '/pg_hba.conf'); + # Each specified user uses the given method + foreach my $user (@users) + { + $node->append_conf('pg_hba.conf', "local all $user $hba_method\n"); + } + # Others use trust + $node->append_conf('pg_hba.conf', "local all all trust\n"); + $node->reload; +} + +# 1. Initialize and start the PostgreSQL cluster +my $node = PostgreSQL::Test::Cluster->new('main'); +# allows_streaming => 'logical' sets wal_level and max_wal_senders high +# enough to accept a replication=database connection, needed by Test 11. +$node->init(allows_streaming => 'logical'); + +# Enable credential validation with short interval (5 seconds minimum) +$node->append_conf('postgresql.conf', "credential_validation_enabled = on\n"); +$node->append_conf('postgresql.conf', "credential_validation_interval = 5\n"); + +# allows_streaming's max_connections = 10 (meant for running several +# concurrent test postmasters) is too low for this file's many simultaneous +# background_psql sessions; raise it back up. +$node->append_conf('postgresql.conf', "max_connections = 30\n"); + +$node->start; + +############################################################################# +# Tests 1-5, 7a-7b and 10 all just need "a session, with some credential- or +# role-affecting change applied to it, revalidated after one interval +# elapses". They use independent users/roles, so all of their setup and +# mutations are done up front here and they share a single sleep() for that +# interval to elapse, rather than each test waiting out its own separate +# interval. +# +# Test 6 (credential_validation_enabled turned off) and Test 8 (repeated +# ERRORs) each get their own isolated timing window below instead: Test 6 +# flips a server-wide GUC that would suppress validation for every other +# session in this batch, and Test 8 needs its own sub-interval-granularity +# sleep loop. +############################################################################# +note "=== Setting up Tests 1-5, 7a-7b, 10 (batched validation window) ==="; + +$node->safe_psql('postgres', "CREATE USER user1 LOGIN PASSWORD 'secret1';"); +$node->safe_psql('postgres', "CREATE USER user2 LOGIN PASSWORD 'secret2';"); +$node->safe_psql('postgres', "CREATE USER user3 LOGIN PASSWORD 'secret3';"); +$node->safe_psql('postgres', "CREATE USER user4 LOGIN PASSWORD 'secret4';"); +$node->safe_psql('postgres', "CREATE USER user5 LOGIN;"); +$node->safe_psql('postgres', "CREATE USER user10 LOGIN PASSWORD 'secret10';"); +$node->safe_psql('postgres', "CREATE USER authsuper1 LOGIN SUPERUSER PASSWORD 'secret7a';"); +$node->safe_psql('postgres', "CREATE ROLE sesstarget1;"); +$node->safe_psql('postgres', "CREATE USER authsuper2 LOGIN SUPERUSER PASSWORD 'secret7b';"); +$node->safe_psql('postgres', "CREATE ROLE sesstarget2;"); + +# user5 (Test 5) relies on the "all trust" fallback reset_pg_hba always +# appends, since trust-authenticated sessions have no authn_id and are +# skipped by credential validation entirely -- that's the very thing Test 5 +# is checking. +reset_pg_hba($node, 'md5', 'user1', 'user2', 'user3', 'user4', 'user10', + 'authsuper1', 'authsuper2'); + +############################################################################# +# Test 1 setup: VALID UNTIL expiration +############################################################################# +$ENV{PGPASSWORD} = 'secret1'; +my $session1 = $node->background_psql( + 'postgres', + on_error_stop => 0, + extra_params => ['-U', 'user1'] +); + +my ($stdout, $ret) = $session1->query('SELECT 1 AS success;'); +like($stdout, qr/1/, 'user1 can execute queries initially'); +is($ret, 0, 'no errors during initial query for user1'); + +############################################################################# +# Test 2 setup: user dropped while session is active +############################################################################# +$ENV{PGPASSWORD} = 'secret2'; +my $session2 = $node->background_psql( + 'postgres', + on_error_stop => 0, + extra_params => ['-U', 'user2'] +); + +($stdout, $ret) = $session2->query('SELECT 1 AS success;'); +like($stdout, qr/1/, 'user2 can execute queries initially'); +is($ret, 0, 'no errors during initial query for user2'); + +############################################################################# +# Test 3 setup: VALID UNTIL extended keeps session alive (positive test) +############################################################################# +$ENV{PGPASSWORD} = 'secret3'; +my $session3 = $node->background_psql( + 'postgres', + on_error_stop => 0, + extra_params => ['-U', 'user3'] +); + +############################################################################# +# Test 4 setup: multiple sessions terminated when the same user expires +############################################################################# +$ENV{PGPASSWORD} = 'secret4'; +my $session4a = $node->background_psql( + 'postgres', + on_error_stop => 0, + extra_params => ['-U', 'user4'] +); +my $session4b = $node->background_psql( + 'postgres', + on_error_stop => 0, + extra_params => ['-U', 'user4'] +); + +($stdout, $ret) = $session4a->query('SELECT 1;'); +like($stdout, qr/1/, 'session4a works initially'); +($stdout, $ret) = $session4b->query('SELECT 1;'); +like($stdout, qr/1/, 'session4b works initially'); + +############################################################################# +# Test 5 setup: trust auth sessions are not affected +############################################################################# +delete $ENV{PGPASSWORD}; +my $session5 = $node->background_psql( + 'postgres', + on_error_stop => 0, + extra_params => ['-U', 'user5'] +); + +############################################################################# +# Test 7a/7b setup: SET SESSION AUTHORIZATION does not change whose +# credentials are checked. Regression test for using +# GetAuthenticatedUserId() (the role that actually authenticated) rather +# than GetSessionUserId() (the mutable current session role) in the +# baseline role-validity check. +# +# authsuperN logs in over the wire, so its credentials are what actually got +# authenticated. sesstargetN is only ever reached via SET SESSION +# AUTHORIZATION, never over the wire, so it needs no pg_hba entry or +# password of its own. +############################################################################# +$ENV{PGPASSWORD} = 'secret7a'; +my $session7a = $node->background_psql( + 'postgres', + on_error_stop => 0, + extra_params => ['-U', 'authsuper1'] +); + +($stdout, $ret) = $session7a->query('SET SESSION AUTHORIZATION sesstarget1;'); +is($ret, 0, 'Test 7a: SET SESSION AUTHORIZATION succeeds for superuser'); +($stdout, $ret) = $session7a->query('SELECT current_user;'); +like($stdout, qr/sesstarget1/, 'Test 7a: session is now running as sesstarget1'); + +$ENV{PGPASSWORD} = 'secret7b'; +my $session7b = $node->background_psql( + 'postgres', + on_error_stop => 0, + extra_params => ['-U', 'authsuper2'] +); + +($stdout, $ret) = $session7b->query('SET SESSION AUTHORIZATION sesstarget2;'); +is($ret, 0, 'Test 7b: SET SESSION AUTHORIZATION succeeds for superuser'); +($stdout, $ret) = $session7b->query('SELECT current_user;'); +like($stdout, qr/sesstarget2/, 'Test 7b: session is now running as sesstarget2'); + +############################################################################# +# Test 10 setup: idle session terminated in real time, without ever sending +# another command. Regression test for the idle-session enforcement gap: +# credential validation used to only run right after ReadCommand() returned +# a message (i.e. at the next command boundary), so a session that received +# no further commands after its credentials expired would sit in +# pg_stat_activity indefinitely, unlike idle_session_timeout which fires in +# real time even while blocked waiting for the next client message. +# ProcessClientReadInterrupt() now also runs a validation cycle from that +# same idle-wait point, so $session10 is never sent another query for the +# rest of this test -- only pg_stat_activity and the server log are +# observed. +############################################################################# +$ENV{PGPASSWORD} = 'secret10'; +my $session10 = $node->background_psql( + 'postgres', + on_error_stop => 0, + extra_params => ['-U', 'user10'] +); + +($stdout, $ret) = $session10->query('SELECT pg_backend_pid();'); +my $pid10; +$pid10 = $1 if $stdout =~ /(\d+)/; +ok(defined $pid10, 'Test 10: got user10 backend pid'); + +############################################################################# +# Apply every mutation for this batch, then wait out a single validation +# interval that covers all of them. +############################################################################# +$node->safe_psql('postgres', "ALTER USER user1 VALID UNTIL '2025-11-02 16:59:37+05:30';"); +$node->safe_psql('postgres', "DROP USER user2;"); +$node->safe_psql('postgres', "ALTER USER user3 VALID UNTIL '2099-12-31 23:59:59';"); +$node->safe_psql('postgres', "ALTER USER user4 VALID UNTIL '2020-01-01';"); +$node->safe_psql('postgres', "ALTER USER user5 VALID UNTIL '2020-01-01';"); # no-op: trust has no authn_id +$node->safe_psql('postgres', "ALTER USER authsuper1 VALID UNTIL '2020-01-01';"); +$node->safe_psql('postgres', "ALTER ROLE sesstarget2 VALID UNTIL '2020-01-01';"); # session role only, irrelevant +$node->safe_psql('postgres', "ALTER USER user10 VALID UNTIL '2020-01-01';"); + +note "Waiting 7 seconds for one shared credential validation cycle to cover Tests 1-5, 7a-7b, and 10..."; +sleep(7); + +############################################################################# +# Test 1 checks +############################################################################# +eval { + ($stdout, $ret) = $session1->query('SELECT 2 AS failure_expected;'); +}; + +my $log_contents = slurp_file($node->logfile); +like( + $log_contents, + qr/FATAL:.*session credentials have expired/, + 'Test 1: server log shows session terminated due to expired credentials' +); +like( + $log_contents, + qr/DETAIL:.*role validity check failed for user "user1": role has passed its VALID UNTIL expiration/, + 'Test 1: server log DETAIL identifies the user and the VALID UNTIL reason' +); + +eval { $session1->quit; }; + +############################################################################# +# Test 2 checks +############################################################################# +eval { + ($stdout, $ret) = $session2->query('SELECT 2 AS failure_expected;'); +}; + +$log_contents = slurp_file($node->logfile); +like( + $log_contents, + qr/FATAL:.*session credentials have expired/, + 'Test 2: server log shows session terminated after user was dropped' +); +like( + $log_contents, + qr/DETAIL:.*role validity check failed for user "user2": role no longer exists/, + 'Test 2: server log DETAIL identifies the user and the dropped-role reason' +); + +eval { $session2->quit; }; + +############################################################################# +# Test 3 checks (positive) +############################################################################# +($stdout, $ret) = $session3->query('SELECT 1 AS still_alive;'); +like($stdout, qr/1/, 'Test 3: session remains alive with valid VALID UNTIL'); +is($ret, 0, 'Test 3: no errors when VALID UNTIL is in the future'); + +eval { $session3->quit; }; + +############################################################################# +# Test 4 checks +############################################################################# +eval { $session4a->query('SELECT 2;'); }; +eval { $session4b->query('SELECT 2;'); }; + +$log_contents = slurp_file($node->logfile); +# Count occurrences of the termination message +my @matches = ($log_contents =~ /FATAL:.*session credentials have expired/g); +cmp_ok(scalar(@matches), '>=', 3, 'Test 4: multiple sessions terminated for same user'); + +eval { $session4a->quit; }; +eval { $session4b->quit; }; + +############################################################################# +# Test 5 checks +############################################################################# +($stdout, $ret) = $session5->query('SELECT 1 AS trust_still_works;'); +like($stdout, qr/1/, 'Test 5: trust auth session not terminated (no validator)'); + +eval { $session5->quit; }; + +############################################################################# +# Test 7a checks: the *originally authenticated* role's expiry still +# terminates the session even after switching to a still-valid session +# role. If the baseline check regressed to GetSessionUserId(), this session +# would survive instead (sesstarget1 is never touched), so this test would +# catch that. +############################################################################# +eval { + ($stdout, $ret) = $session7a->query('SELECT 2 AS failure_expected;'); +}; + +$log_contents = slurp_file($node->logfile); +like( + $log_contents, + qr/FATAL:.*session credentials have expired/, + 'Test 7a: session terminated on original login role expiry, despite SET SESSION AUTHORIZATION to a still-valid role' +); +like( + $log_contents, + qr/DETAIL:.*role validity check failed for user "authsuper1": role has passed its VALID UNTIL expiration/, + 'Test 7a: server log DETAIL identifies the originally authenticated role, not the session role' +); + +eval { $session7a->quit; }; + +############################################################################# +# Test 7b checks: the *current session role's* own expiry is irrelevant -- +# only the originally authenticated role is checked. If the baseline check +# regressed to GetSessionUserId(), this session would be wrongly terminated +# instead of surviving, so this test would catch that. +############################################################################# +($stdout, $ret) = $session7b->query('SELECT 1 AS still_alive;'); +like($stdout, qr/1/, 'Test 7b: session survives when only the session role (not the login role) has expired'); +is($ret, 0, 'Test 7b: no errors -- validation checks the authenticated role, not the session role'); + +eval { $session7b->quit; }; + +############################################################################# +# Test 10 checks +############################################################################# +my $still_present = $node->safe_psql('postgres', + "SELECT count(*) FROM pg_stat_activity WHERE pid = $pid10;"); +is($still_present, '0', + 'Test 10: idle session no longer present in pg_stat_activity, without sending it another command' +); + +$log_contents = slurp_file($node->logfile); +like( + $log_contents, + qr/DETAIL:.*role validity check failed for user "user10": role has passed its VALID UNTIL expiration/, + 'Test 10: server log shows the idle session was terminated in real time, without ever sending it another command' +); + +eval { $session10->quit; }; + +############################################################################# +# Test 6: Credential validation disabled +# +# Kept isolated from the batch above: this flips credential_validation_enabled +# off for the whole server, which would suppress validation for every other +# session in that batch too. +############################################################################# +note "=== Test 6: Credential validation disabled ==="; + +# Disable credential validation +$node->safe_psql('postgres', "ALTER SYSTEM SET credential_validation_enabled = off;"); +$node->reload; + +$node->safe_psql('postgres', "CREATE USER user6 LOGIN PASSWORD 'secret6';"); +reset_pg_hba($node, 'md5', 'user6'); + +$ENV{PGPASSWORD} = 'secret6'; +my $session6 = $node->background_psql( + 'postgres', + on_error_stop => 0, + extra_params => ['-U', 'user6'] +); + +# Expire user6 +$node->safe_psql('postgres', "ALTER USER user6 VALID UNTIL '2020-01-01';"); + +note "Waiting 7 seconds..."; +sleep(7); + +# Session should still work since validation is disabled +($stdout, $ret) = $session6->query('SELECT 1 AS validation_disabled;'); +like($stdout, qr/1/, 'Test 6: session survives when validation is disabled'); + +eval { $session6->quit; }; + +# Re-enable for any subsequent tests +$node->safe_psql('postgres', "ALTER SYSTEM SET credential_validation_enabled = on;"); +$node->reload; + +############################################################################# +# Test 8: Repeated ERRORs cannot indefinitely delay mandatory revalidation. +# +# Every top-level ERROR cancels all active timeouts (disable_all_timeouts() +# in PostgresMain()), including the credential validation timer. A client +# that keeps triggering a harmless error more often than the validation +# interval must not be able to use that to postpone revalidation forever. +############################################################################# +note "=== Test 8: repeated ERRORs cannot indefinitely delay revalidation ==="; + +# Only inspect log content generated from this point on, so this test can't +# be satisfied by a FATAL message left over from an earlier test. +my $test8_log_offset = -s $node->logfile; + +$node->safe_psql('postgres', "CREATE USER user8 LOGIN PASSWORD 'secret8b';"); +reset_pg_hba($node, 'md5', 'user8'); + +$ENV{PGPASSWORD} = 'secret8b'; +my $session8 = $node->background_psql( + 'postgres', + on_error_stop => 0, + extra_params => ['-U', 'user8'] +); + +($stdout, $ret) = $session8->query('SELECT 1 AS success;'); +like($stdout, qr/1/, 'Test 8: user8 can execute queries initially'); + +# Expire user8 right away. +$node->safe_psql('postgres', "ALTER USER user8 VALID UNTIL '2020-01-01';"); + +# Simulate a rogue client firing a harmless error roughly once a second -- +# well inside the 5 second validation interval each time -- to try to keep +# resetting the validation timer before it can ever fire. +for (1 .. 8) +{ + eval { $session8->query('SELECT 1/0;'); }; + sleep(1); +} + +# Despite the constant stream of errors, the session must still be +# terminated: the original validation deadline must not be pushed back by +# each error's timeout cancellation. +eval { + ($stdout, $ret) = $session8->query('SELECT 2 AS failure_expected;'); +}; + +$log_contents = slurp_file($node->logfile, $test8_log_offset); +like( + $log_contents, + qr/FATAL:.*session credentials have expired/, + 'Test 8: session terminated despite repeated ERRORs attempting to delay validation' +); + +eval { $session8->quit; }; + +############################################################################# +# Test 9: credential_validation_enabled/credential_validation_interval +# cannot be changed within an already-open session (PGC_SU_BACKEND), even by +# a superuser. This is what closes the gap raised in review: since the +# whole point of this feature is to catch a session whose credentials became +# invalid after authentication, an already-connected session -- including a +# superuser's -- must not be able to silently disable the check on itself to +# dodge its own credential revocation. +############################################################################# +note "=== Test 9: credential_validation_* cannot be changed mid-session (PGC_SU_BACKEND) ==="; + +my ($psql_ret, $psql_stdout, $psql_stderr); + +($psql_ret, $psql_stdout, $psql_stderr) = $node->psql( + 'postgres', + 'SET credential_validation_enabled = off;'); +isnt($psql_ret, 0, + 'Test 9: SET credential_validation_enabled fails within a session'); +like( + $psql_stderr, + qr/cannot be set after connection start/, + 'Test 9: credential_validation_enabled cannot be changed mid-session, even by a superuser' +); + +($psql_ret, $psql_stdout, $psql_stderr) = $node->psql( + 'postgres', + 'SET credential_validation_interval = 3600;'); +isnt($psql_ret, 0, + 'Test 9: SET credential_validation_interval fails within a session'); +like( + $psql_stderr, + qr/cannot be set after connection start/, + 'Test 9: credential_validation_interval cannot be changed mid-session, even by a superuser' +); + +############################################################################# +# Test 11: a logical replication protocol connection (replication=database) +# can run ordinary SQL, and must be validated like any other session. +# +# EnableCredentialValidationTimeout()/CheckCredentialValidity() used to +# unconditionally skip every walsender (AmWalSenderProcess()), including a +# database-connected (logical) one -- even though, unlike physical +# replication, such a connection is explicitly allowed to run arbitrary SQL +# through the normal command dispatch loop before (or between) replication +# commands. That let a logical-replication-protocol session with expired +# credentials keep running indefinitely. Both functions now only skip +# *physical* replication connections (!am_db_walsender), so this session must +# be terminated exactly like an ordinary one. +# +# This only exercises the pre-streaming phase (running plain SQL over a +# replication=database connection); the equivalent check while actively +# streaming is serviced separately by WalSndHandleCredentialValidation() in +# walsender.c and is not exercised here. +############################################################################# +note "=== Test 11: logical replication connection (replication=database) is validated ==="; + +$node->safe_psql('postgres', + "CREATE USER user11 LOGIN REPLICATION PASSWORD 'secret11';"); +reset_pg_hba($node, 'md5', 'user11'); + +$ENV{PGPASSWORD} = 'secret11'; +my $session11 = $node->background_psql( + 'postgres', + on_error_stop => 0, + replication => 'database', + extra_params => ['-U', 'user11'] +); + +($stdout, $ret) = $session11->query('SELECT 1 AS success;'); +like($stdout, qr/1/, + 'Test 11: logical replication connection can run ordinary SQL initially'); +is($ret, 0, 'Test 11: no errors during initial query for user11'); + +$node->safe_psql('postgres', "ALTER USER user11 VALID UNTIL '2020-01-01';"); + +note "Waiting 7 seconds for a credential validation cycle..."; +sleep(7); + +eval { + ($stdout, $ret) = $session11->query('SELECT 2 AS failure_expected;'); +}; + +$log_contents = slurp_file($node->logfile); +like( + $log_contents, + qr/FATAL:.*session credentials have expired/, + 'Test 11: logical replication session terminated due to expired credentials' +); +like( + $log_contents, + qr/DETAIL:.*role validity check failed for user "user11": role has passed its VALID UNTIL expiration/, + 'Test 11: server log DETAIL identifies the user and the VALID UNTIL reason' +); + +eval { $session11->quit; }; + +# Clean up +$node->stop; +done_testing(); -- 2.52.0