From d64dc56d9dc3e9ba334147505b6dd998a05ae9a6 Mon Sep 17 00:00:00 2001
From: Ajit Awekar <ajit.awekar@enterprisedb.com>
Date: Tue, 30 Jun 2026 17:51:09 +0530
Subject: [PATCH 5/5] Add LDAP account-existence check to credential validation

For LDAP-authenticated sessions, re-run the configured search filter on each
validation cycle and terminate the session once the account no longer exists
in the directory.  Search+bind only; simple-bind sessions are treated as valid.
---
 src/backend/libpq/auth-validate-methods.c     |  37 ++++
 src/backend/libpq/auth-validate.c             |   2 +
 src/backend/libpq/auth.c                      | 173 ++++++++++++++++++
 src/include/libpq/auth-validate.h             |   1 +
 src/include/libpq/auth.h                      |   9 +
 src/test/ldap/meson.build                     |   1 +
 .../ldap/t/004_ldap_continuous_validation.pl  | 138 ++++++++++++++
 7 files changed, 361 insertions(+)
 create mode 100644 src/test/ldap/t/004_ldap_continuous_validation.pl

diff --git a/src/backend/libpq/auth-validate-methods.c b/src/backend/libpq/auth-validate-methods.c
index 7039b3201b8..e8dc2eddf2c 100644
--- a/src/backend/libpq/auth-validate-methods.c
+++ b/src/backend/libpq/auth-validate-methods.c
@@ -20,6 +20,7 @@
 #include "postgres.h"
 
 #include "catalog/pg_authid.h"
+#include "libpq/auth.h"
 #include "libpq/auth-validate-methods.h"
 #include "libpq/auth-validate.h"
 #include "libpq/libpq-be.h"
@@ -32,6 +33,7 @@
 static bool validate_oauth_credentials(void);
 static bool validate_cert_credentials(void);
 static bool validate_gss_credentials(void);
+static bool validate_ldap_credentials(void);
 
 /*
  * Initialize validation methods
@@ -48,6 +50,7 @@ InitializeValidationMethods(void)
 	RegisterCredentialValidator(CVT_OAUTH, validate_oauth_credentials);
 	RegisterCredentialValidator(CVT_CERT, validate_cert_credentials);
 	RegisterCredentialValidator(CVT_GSS, validate_gss_credentials);
+	RegisterCredentialValidator(CVT_LDAP, validate_ldap_credentials);
 }
 
 /*
@@ -167,3 +170,37 @@ validate_gss_credentials(void)
 
 	return true;
 }
+
+/*
+ * Validate LDAP credentials.
+ *
+ * Unlike OAuth/cert/GSS, an LDAP-authenticated session retains no credential
+ * with an intrinsic expiry: the password presented at connection time is used
+ * for the bind and then discarded.  What we can re-check is whether the
+ * account still exists (and still satisfies the configured search filter) in
+ * the directory.  This is delegated to CheckLDAPCredentialValidity(), which
+ * re-binds with the configured search credentials and re-runs the search
+ * filter; see the comment there for the details and limitations.
+ *
+ * Note that this check, by its nature, performs a network round-trip to the
+ * LDAP server on every validation cycle, so a larger
+ * credential_validation_interval is advisable for LDAP-authenticated sessions.
+ *
+ * Returns false if the user is gone from the directory, true otherwise.
+ */
+static bool
+validate_ldap_credentials(void)
+{
+#ifdef USE_LDAP
+	Port	   *port = MyProcPort;
+
+	if (port == NULL)
+		return true;
+
+	/* The session is no longer valid once the LDAP account disappears */
+	if (!CheckLDAPCredentialValidity(port))
+		return false;
+#endif
+
+	return true;
+}
diff --git a/src/backend/libpq/auth-validate.c b/src/backend/libpq/auth-validate.c
index e39a33b71a9..2ccd03066c0 100644
--- a/src/backend/libpq/auth-validate.c
+++ b/src/backend/libpq/auth-validate.c
@@ -50,6 +50,8 @@ UserAuthToValidationType(UserAuth auth_method)
 			return CVT_CERT;
 		case uaGSS:
 			return CVT_GSS;
+		case uaLDAP:
+			return CVT_LDAP;
 		default:
 			/*
 			 * No method-specific validator for other auth methods.  Password
diff --git a/src/backend/libpq/auth.c b/src/backend/libpq/auth.c
index 2af5615e54a..fe166320536 100644
--- a/src/backend/libpq/auth.c
+++ b/src/backend/libpq/auth.c
@@ -146,6 +146,7 @@ static int	CheckBSDAuth(Port *port, char *user);
 #endif
 
 static int	CheckLDAPAuth(Port *port);
+static void save_ldap_validation_params(Port *port);
 
 /* LDAP_OPT_DIAGNOSTIC_MESSAGE is the newer spelling */
 #ifndef LDAP_OPT_DIAGNOSTIC_MESSAGE
@@ -156,6 +157,15 @@ static int	CheckLDAPAuth(Port *port);
 static char *dummy_ldap_password_mutator(char *input);
 auth_password_hook_typ ldap_password_hook = dummy_ldap_password_mutator;
 
+/*
+ * Session-lifetime snapshot of the pg_hba LDAP settings, taken at
+ * authentication time and used later by CheckLDAPCredentialValidity().  The
+ * HbaLine that port->hba points at lives in PostmasterContext, which is
+ * destroyed before the backend enters its main command loop, so the settings
+ * needed to re-validate the session must be copied somewhere longer-lived.
+ */
+static HbaLine *ldap_validation_hba = NULL;
+
 #endif							/* USE_LDAP */
 
 /*----------------------------------------------------------------
@@ -2662,6 +2672,12 @@ CheckLDAPAuth(Port *port)
 	/* Save the original bind DN as the authenticated identity. */
 	set_authn_id(port, fulluser);
 
+	/*
+	 * Snapshot the LDAP settings while port->hba is still valid, so that
+	 * continuous credential validation can re-check the account later.
+	 */
+	save_ldap_validation_params(port);
+
 	ldap_unbind(ldap);
 	pfree(passwd);
 	pfree(fulluser);
@@ -2669,6 +2685,163 @@ CheckLDAPAuth(Port *port)
 	return STATUS_OK;
 }
 
+/*
+ * Copy the LDAP-related pg_hba settings needed for credential re-validation
+ * into TopMemoryContext, where they survive for the life of the session.  See
+ * the comment on ldap_validation_hba above for why this is necessary.
+ */
+static void
+save_ldap_validation_params(Port *port)
+{
+	HbaLine    *src = port->hba;
+	HbaLine    *dst;
+
+	dst = (HbaLine *) MemoryContextAllocZero(TopMemoryContext, sizeof(HbaLine));
+
+	dst->ldaptls = src->ldaptls;
+	dst->ldapport = src->ldapport;
+	dst->ldapscope = src->ldapscope;
+
+#define LDAP_SAVE_STR(field) \
+	dst->field = src->field ? MemoryContextStrdup(TopMemoryContext, src->field) : NULL
+
+	LDAP_SAVE_STR(ldapscheme);
+	LDAP_SAVE_STR(ldapserver);
+	LDAP_SAVE_STR(ldapbinddn);
+	LDAP_SAVE_STR(ldapbindpasswd);
+	LDAP_SAVE_STR(ldapsearchattribute);
+	LDAP_SAVE_STR(ldapsearchfilter);
+	LDAP_SAVE_STR(ldapbasedn);
+
+#undef LDAP_SAVE_STR
+
+	ldap_validation_hba = dst;
+}
+
+/*
+ * Re-validate an LDAP-authenticated session for continuous credential
+ * validation.
+ *
+ * An LDAP session keeps no credential with an intrinsic expiry (the user's
+ * password is discarded right after the bind), so "still valid" is defined
+ * here as: the user still exists in the directory and still satisfies the
+ * configured search filter.  We re-bind with the configured search
+ * credentials (ldapbinddn / ldapbindpasswd) and re-run the same filter that
+ * was used at authentication time:
+ *
+ *   - exactly one matching entry -> still valid
+ *   - no matching entry          -> the account has been deleted, moved out of
+ *                                   the base DN, or no longer matches the
+ *                                   filter (e.g. a filter that excludes
+ *                                   disabled accounts) -> no longer valid
+ *
+ * This requires search+bind mode (ldapbasedn set): in simple-bind mode there
+ * are no retained credentials to bind with, so there is nothing to re-check
+ * and the session is treated as valid.  Likewise, any operational failure
+ * (cannot connect, bind fails, search errors, ambiguous result) is treated
+ * conservatively as "still valid", so that a transient directory or network
+ * problem does not tear down established sessions; only a definitive "user is
+ * gone" result (zero entries) terminates the session.
+ *
+ * Returns true if the session should be considered still valid, false if the
+ * LDAP account is gone.
+ */
+bool
+CheckLDAPCredentialValidity(Port *port)
+{
+	HbaLine    *hba = ldap_validation_hba;
+	Port		vport;
+	LDAP	   *ldap;
+	int			r;
+	char	   *filter;
+	LDAPMessage *search_message = NULL;
+	char	   *attributes[] = {LDAP_NO_ATTRS, NULL};
+	int			count;
+	char	   *c;
+
+	/*
+	 * Only search+bind mode can be re-validated; simple bind (ldapprefix /
+	 * ldapsuffix) retains no credentials with which to query the directory.
+	 * A missing snapshot means this is not a search+bind LDAP session.
+	 */
+	if (hba == NULL || hba->ldapbasedn == NULL)
+		return true;
+
+	/*
+	 * As in CheckLDAPAuth, refuse user names that could inject filter syntax.
+	 * Such a name could not have authenticated in the first place, so just
+	 * treat it as still valid rather than risk a malformed search.
+	 */
+	for (c = port->user_name; *c; c++)
+	{
+		if (*c == '*' || *c == '(' || *c == ')' || *c == '\\' || *c == '/')
+			return true;
+	}
+
+	/*
+	 * InitializeLDAPConnection() reads its settings from port->hba, but the
+	 * real port->hba has been freed by now (see ldap_validation_hba).  Point a
+	 * throwaway Port at our session-lifetime snapshot instead.
+	 */
+	vport = *port;
+	vport.hba = hba;
+
+	if (InitializeLDAPConnection(&vport, &ldap) == STATUS_ERROR)
+		return true;			/* error already logged; fail open */
+
+	/* Bind with the search credentials (anonymous if none configured). */
+	r = ldap_simple_bind_s(ldap,
+						   hba->ldapbinddn ? hba->ldapbinddn : "",
+						   hba->ldapbindpasswd ? ldap_password_hook(hba->ldapbindpasswd) : "");
+	if (r != LDAP_SUCCESS)
+	{
+		ereport(LOG,
+				(errmsg("could not perform LDAP bind for credential validation of ldapbinddn \"%s\": %s",
+						hba->ldapbinddn ? hba->ldapbinddn : "",
+						ldap_err2string(r)),
+				 errdetail_for_ldap(ldap)));
+		ldap_unbind(ldap);
+		return true;
+	}
+
+	/* Build the same filter that was used at authentication time. */
+	if (hba->ldapsearchfilter)
+		filter = FormatSearchFilter(hba->ldapsearchfilter, port->user_name);
+	else if (hba->ldapsearchattribute)
+		filter = psprintf("(%s=%s)", hba->ldapsearchattribute, port->user_name);
+	else
+		filter = psprintf("(uid=%s)", port->user_name);
+
+	r = ldap_search_s(ldap, hba->ldapbasedn, hba->ldapscope,
+					  filter, attributes, 0, &search_message);
+
+	if (r != LDAP_SUCCESS)
+	{
+		ereport(LOG,
+				(errmsg("could not search LDAP for filter \"%s\" during credential validation: %s",
+						filter, ldap_err2string(r)),
+				 errdetail_for_ldap(ldap)));
+		if (search_message != NULL)
+			ldap_msgfree(search_message);
+		ldap_unbind(ldap);
+		pfree(filter);
+		return true;			/* operational error; fail open */
+	}
+
+	count = ldap_count_entries(ldap, search_message);
+
+	ldap_msgfree(search_message);
+	ldap_unbind(ldap);
+	pfree(filter);
+
+	/*
+	 * Zero entries is a definitive "user is gone"; terminate the session.  An
+	 * ambiguous (>1) result should not happen for an account that
+	 * authenticated earlier, so treat it conservatively as still valid.
+	 */
+	return (count != 0);
+}
+
 /*
  * Add a detail error message text to the current error if one can be
  * constructed from the LDAP 'diagnostic message'.
diff --git a/src/include/libpq/auth-validate.h b/src/include/libpq/auth-validate.h
index 5a0e1df4dcf..aaea4a28e8e 100644
--- a/src/include/libpq/auth-validate.h
+++ b/src/include/libpq/auth-validate.h
@@ -22,6 +22,7 @@ typedef enum CredentialValidationType
 	CVT_OAUTH = 0,				/* OAuth bearer token authentication */
 	CVT_CERT,					/* TLS client certificate authentication */
 	CVT_GSS,					/* GSSAPI/Kerberos authentication */
+	CVT_LDAP,					/* LDAP authentication */
 	CVT_COUNT					/* Total number of credential validation types */
 } CredentialValidationType;
 
diff --git a/src/include/libpq/auth.h b/src/include/libpq/auth.h
index be23c4ca3e4..faa75e35202 100644
--- a/src/include/libpq/auth.h
+++ b/src/include/libpq/auth.h
@@ -51,4 +51,13 @@ typedef char *(*auth_password_hook_typ) (char *input);
 /* Default LDAP password mutator hook, can be overridden by a shared library */
 extern PGDLLIMPORT auth_password_hook_typ ldap_password_hook;
 
+#ifdef USE_LDAP
+/*
+ * Re-check that an LDAP-authenticated session's account is still valid in the
+ * directory.  Used by continuous credential validation.  Returns true if the
+ * session is still considered valid, false if the account is gone.
+ */
+extern bool CheckLDAPCredentialValidity(Port *port);
+#endif
+
 #endif							/* AUTH_H */
diff --git a/src/test/ldap/meson.build b/src/test/ldap/meson.build
index d8961e6c8d7..bdad7cb8409 100644
--- a/src/test/ldap/meson.build
+++ b/src/test/ldap/meson.build
@@ -9,6 +9,7 @@ tests += {
       't/001_auth.pl',
       't/002_bindpasswd.pl',
       't/003_ldap_connection_param_lookup.pl',
+      't/004_ldap_continuous_validation.pl',
     ],
     'env': {
       'with_ldap': ldap.found() ? 'yes' : 'no',
diff --git a/src/test/ldap/t/004_ldap_continuous_validation.pl b/src/test/ldap/t/004_ldap_continuous_validation.pl
new file mode 100644
index 00000000000..6cce03cb043
--- /dev/null
+++ b/src/test/ldap/t/004_ldap_continuous_validation.pl
@@ -0,0 +1,138 @@
+
+# Copyright (c) 2021-2026, PostgreSQL Global Development Group
+
+# Test continuous credential validation for LDAP-authenticated sessions.
+#
+# Unlike OAuth/cert/GSS, an LDAP session retains no credential with an
+# intrinsic expiry: the password presented at connection time is used for the
+# bind and then discarded.  With credential_validation_enabled, the backend
+# should instead periodically re-check that the authenticated account still
+# exists in the directory (and still matches the configured search filter), and
+# terminate the session once it is gone.
+#
+# This test (search+bind mode) opens an LDAP-authenticated session, deletes the
+# user's entry from the directory while the session sits idle, and then
+# verifies that the next command is rejected with a FATAL "session credentials
+# have expired".
+#
+# Like 001_auth.pl this requires a full OpenLDAP installation; see the README
+# for details.
+
+use strict;
+use warnings FATAL => 'all';
+
+use FindBin;
+use lib "$FindBin::RealBin/..";
+
+use LdapServer;
+use PostgreSQL::Test::Utils;
+use PostgreSQL::Test::Cluster;
+use Test::More;
+
+if ($ENV{with_ldap} ne 'yes')
+{
+	plan skip_all => 'LDAP not supported by this build';
+}
+elsif (!$ENV{PG_TEST_EXTRA} || $ENV{PG_TEST_EXTRA} !~ /\bldap\b/)
+{
+	plan skip_all =>
+	  'Potentially unsafe test LDAP not enabled in PG_TEST_EXTRA';
+}
+elsif (!$LdapServer::setup)
+{
+	plan skip_all => $LdapServer::setup_error;
+}
+
+# Timing parameters.  credential_validation_interval has a 5s minimum, so sit
+# idle comfortably longer than that (and longer than the interval) to be sure
+# the validation timer has fired before the next command is issued.
+my $validation_interval = 5;
+my $idle_seconds = 12;
+
+my $username = 'test1';
+my $user_dn = "uid=$username,dc=example,dc=net";
+
+note "setting up LDAP server";
+
+my $ldap_rootpw = 'secret';
+my $ldap = LdapServer->new($ldap_rootpw, 'anonymous');    # use anonymous auth
+$ldap->ldapadd_file('authdata.ldif');
+$ldap->ldapsetpw($user_dn, 'secret1');
+
+my ($ldap_server, $ldap_port, $ldap_basedn, $ldap_rootdn, $ldap_pwfile,
+	$ldap_url)
+  = $ldap->prop(qw(server port basedn rootdn pwfile url));
+
+note "setting up PostgreSQL instance";
+
+my $node = PostgreSQL::Test::Cluster->new('node');
+$node->init;
+$node->append_conf(
+	'postgresql.conf', qq{
+listen_addresses = '127.0.0.1'
+log_connections = all
+log_min_messages = debug2
+lc_messages = 'C'
+credential_validation_enabled = on
+credential_validation_interval = $validation_interval
+});
+$node->start;
+
+$node->safe_psql('postgres', 'CREATE USER test1;');
+
+unlink($node->data_dir . '/pg_hba.conf');
+$node->append_conf('pg_hba.conf',
+	qq{host all all 127.0.0.1/32 ldap ldapserver=$ldap_server ldapport=$ldap_port ldapbasedn="$ldap_basedn" ldapsearchfilter="(uid=\$username)"}
+);
+$node->restart;
+
+note "running tests";
+
+# Open an LDAP-authenticated session, confirm it is live, then delete the
+# user's directory entry and sit idle past the validation interval before
+# issuing another command.
+#
+# The deletion and the idle wait are done from a client-side "\!" shell step,
+# so the backend waits at a command boundary -- exactly where the validation
+# timer's pending flag is acted upon when the next command arrives.  By then
+# the account is gone, so the re-search finds no entry and the session is
+# terminated.
+my $connstr =
+  $node->connstr('postgres') . " user=$username host=127.0.0.1 sslmode=disable";
+
+my $delete_and_wait =
+  "ldapdelete -x -H $ldap_url -D '$ldap_rootdn' -y $ldap_pwfile '$user_dn' && sleep $idle_seconds";
+
+my $script =
+    "SELECT 'session-live';\n"
+  . "\\! $delete_and_wait\n"
+  . "SELECT 'after-expiry';\n";
+
+local $ENV{PGPASSWORD} = 'secret1';
+
+my ($ret, $stdout, $stderr) = $node->psql(
+	'postgres', $script,
+	connstr => $connstr,
+	extra_params => ['-w']);
+
+isnt($ret, 0, 'LDAP session is terminated once the account is gone');
+like(
+	$stdout,
+	qr/session-live/,
+	'session is live before the LDAP account is removed');
+unlike(
+	$stdout,
+	qr/after-expiry/,
+	'command after the account is removed does not return a result');
+like(
+	$stderr,
+	qr/session credentials have expired/,
+	'session terminated with the credential-expiry FATAL');
+
+# The session must have actually authenticated via LDAP for this to be a
+# meaningful test of LDAP credential validation.
+ok( $node->log_contains(
+		qq{connection authenticated: identity="$user_dn" method=ldap}),
+	'session was authenticated with LDAP');
+
+done_testing();
-- 
2.52.0

