From aec6ad94c1640691f70b0f8d1434a64e25a30755 Mon Sep 17 00:00:00 2001
From: Jacob Champion <pchampion@vmware.com>
Date: Thu, 2 Dec 2021 09:36:36 -0800
Subject: [PATCH 2/4] pg_ident: allow delegation to an LDAP server

This is work-in-progress code, with several deficiencies and TODOs.
Don't use it in production.

The core addition of this patch is the HBA option "ldapmap", which
behaves like the existing "map" option except that the list of Postgres
roles is constructed from the results of an LDAP query. With this
method, role authorization can be controlled centrally, without needing
cron scripts for LDAP-to-Postgres synchronization.

The LDAP query must be in the form of an ldap[s]:// URL and it must
return exactly one entry (for the user that is logging in). That entry's
attribute values are used as the list of roles that an authenticated
user is allowed to log in as. For example, an LDAP admin could give any
authorized users one or more "postgresRole" attributes, and the DBA
could write a role query in pg_ident that looks like

  myldapmap  /^(.*)$  ldaps://example.com/dc=example,dc=com?postgresRole?sub?(uid=\1)

The ldapmap option can only be used for those auth methods that already
support the map option (cert, gss, etc.). The ldaptls, ldapbinddn, and
ldapbindpasswd options for the ldap auth method are now also supported
for any HBA lines that use ldapmap.

Possible remaining work (and known problems):

- It'd be nice to map roles based on LDAP group membership.

- pg_ident's regex substitution into a query URL is fairly unsafe and
  needs to be rethought. For now I've restricted the characters that can
  be substituted.

- The new LDAP tests open up TCP listen_addresses on the test server; I
  don't know if that's actually safe to do.

- Some of the options for specifying the LDAP server are in pg_ident
  (the scheme, host, and port), some are in pg_hba (ldaptls and the bind
  settings), and some are in the environment (client certificates, etc.)
  That is not easy to admin.

- Querying for multiple attributes is allowed, but only the first one
  returned by the server will be honored; the rest are silently ignored.

- The LDAP connection code might need to be deduplicated with
  InitializeLDAPConnection(), as they have substantial similarity.

- There's not much logging to help you when you're writing a role query,
  or to do a postmortem if something is wrong.

- Various others, called out inline...
---
 src/backend/libpq/auth.c          |  13 +-
 src/backend/libpq/hba.c           | 523 ++++++++++++++++++++++++++++--
 src/include/libpq/hba.h           |   5 +-
 src/test/ldap/authdata.ldif       |   3 +
 src/test/ldap/postgresuser.schema |  30 ++
 src/test/ldap/t/001_auth.pl       | 244 ++++++++++++--
 6 files changed, 766 insertions(+), 52 deletions(-)
 create mode 100644 src/test/ldap/postgresuser.schema

diff --git a/src/backend/libpq/auth.c b/src/backend/libpq/auth.c
index 7bcf52523b..c8c48d487d 100644
--- a/src/backend/libpq/auth.c
+++ b/src/backend/libpq/auth.c
@@ -1137,8 +1137,7 @@ pg_GSS_checkauth(Port *port)
 		return STATUS_ERROR;
 	}
 
-	ret = check_usermap(port->hba->usermap, port->user_name, princ,
-						pg_krb_caseins_users);
+	ret = check_usermap(port, princ, pg_krb_caseins_users);
 
 	pfree(princ);
 
@@ -1477,12 +1476,12 @@ pg_SSPI_recvauth(Port *port)
 		int			retval;
 
 		namebuf = psprintf("%s@%s", accountname, domainname);
-		retval = check_usermap(port->hba->usermap, port->user_name, namebuf, true);
+		retval = check_usermap(port, namebuf, true);
 		pfree(namebuf);
 		return retval;
 	}
 	else
-		return check_usermap(port->hba->usermap, port->user_name, accountname, true);
+		return check_usermap(port, accountname, true);
 }
 
 /*
@@ -1843,7 +1842,7 @@ ident_inet_done:
 		 * usermap, because at this point authentication has succeeded.
 		 */
 		set_authn_id(port, ident_user);
-		return check_usermap(port->hba->usermap, port->user_name, ident_user, false);
+		return check_usermap(port, ident_user, false);
 	}
 	return STATUS_ERROR;
 }
@@ -1906,7 +1905,7 @@ auth_peer(hbaPort *port)
 	 */
 	set_authn_id(port, pw->pw_name);
 
-	ret = check_usermap(port->hba->usermap, port->user_name, port->authn_id, false);
+	ret = check_usermap(port, port->authn_id, false);
 
 	return ret;
 #else
@@ -2799,7 +2798,7 @@ CheckCertAuth(Port *port)
 	}
 
 	/* Just pass the certificate cn/dn to the usermap check */
-	status_check_usermap = check_usermap(port->hba->usermap, port->user_name, peer_username, false);
+	status_check_usermap = check_usermap(port, peer_username, false);
 	if (status_check_usermap != STATUS_OK)
 	{
 		/*
diff --git a/src/backend/libpq/hba.c b/src/backend/libpq/hba.c
index 600972e9a4..57440107be 100644
--- a/src/backend/libpq/hba.c
+++ b/src/backend/libpq/hba.c
@@ -49,6 +49,7 @@
 #ifdef WIN32
 #include <winldap.h>
 #else
+#define LDAP_DEPRECATED 1
 #include <ldap.h>
 #endif
 #endif
@@ -1679,6 +1680,53 @@ parse_hba_line(TokenizedLine *tok_line, int elevel)
 		}
 	}
 
+	/* Final sanity checks on options. */
+	if (parsedline->usermap && parsedline->ldapmap)
+	{
+		ereport(elevel,
+				(errcode(ERRCODE_CONFIG_FILE_ERROR),
+				 errmsg("cannot use map together with ldapmap"),
+				 errcontext("line %d of configuration file \"%s\"",
+							line_num, HbaFileName)));
+		*err_msg = "cannot use map together with ldapmap";
+		return NULL;
+	}
+
+	if ((parsedline->auth_method != uaLDAP) && !parsedline->ldapmap)
+	{
+		/*
+		 * Some options are allowed to be set for the LDAP auth method and/or an
+		 * LDAP user mapping, but if neither is in use then we should complain.
+		 */
+		const char *badopt = NULL;
+
+		if (parsedline->ldaptls)
+		{
+			badopt = "ldaptls";
+		}
+		else if (parsedline->ldapbinddn)
+		{
+			badopt = "ldapbinddn";
+		}
+		else if (parsedline->ldapbindpasswd)
+		{
+			badopt = "ldapbindpasswd";
+		}
+
+		if (badopt)
+		{
+			ereport(elevel, \
+					(errcode(ERRCODE_CONFIG_FILE_ERROR), \
+					 errmsg("authentication option \"%s\" is only valid for the \"ldap\" authentication method or an \"ldapmap\" user mapping", \
+							badopt), \
+					 errcontext("line %d of configuration file \"%s\"", \
+							line_num, HbaFileName))); \
+			*err_msg = psprintf("authentication option \"%s\" is only valid for the \"ldap\" authentication method or an \"ldapmap\" user mapping", \
+								badopt); \
+			return NULL;
+		}
+	}
+
 	/*
 	 * Enforce any parameters implied by other settings.
 	 */
@@ -1752,15 +1800,23 @@ parse_hba_auth_opt(char *name, char *val, HbaLine *hbaline,
 	hbaline->ldapscope = LDAP_SCOPE_SUBTREE;
 #endif
 
-	if (strcmp(name, "map") == 0)
+	if (strcmp(name, "map") == 0 || strcmp(name, "ldapmap") == 0)
 	{
 		if (hbaline->auth_method != uaIdent &&
 			hbaline->auth_method != uaPeer &&
 			hbaline->auth_method != uaGSS &&
 			hbaline->auth_method != uaSSPI &&
 			hbaline->auth_method != uaCert)
-			INVALID_AUTH_OPTION("map", gettext_noop("ident, peer, gssapi, sspi, and cert"));
-		hbaline->usermap = pstrdup(val);
+			INVALID_AUTH_OPTION(name, gettext_noop("ident, peer, gssapi, sspi, and cert"));
+
+		if (name[0] == 'l') /* ldapmap */
+		{
+			hbaline->ldapmap = pstrdup(val);
+		}
+		else /* map */
+		{
+			hbaline->usermap = pstrdup(val);
+		}
 	}
 	else if (strcmp(name, "clientcert") == 0)
 	{
@@ -1904,7 +1960,6 @@ parse_hba_auth_opt(char *name, char *val, HbaLine *hbaline,
 	}
 	else if (strcmp(name, "ldaptls") == 0)
 	{
-		REQUIRE_AUTH_OPTION(uaLDAP, "ldaptls", "ldap");
 		if (strcmp(val, "1") == 0)
 			hbaline->ldaptls = true;
 		else
@@ -1943,12 +1998,10 @@ parse_hba_auth_opt(char *name, char *val, HbaLine *hbaline,
 	}
 	else if (strcmp(name, "ldapbinddn") == 0)
 	{
-		REQUIRE_AUTH_OPTION(uaLDAP, "ldapbinddn", "ldap");
 		hbaline->ldapbinddn = pstrdup(val);
 	}
 	else if (strcmp(name, "ldapbindpasswd") == 0)
 	{
-		REQUIRE_AUTH_OPTION(uaLDAP, "ldapbindpasswd", "ldap");
 		hbaline->ldapbindpasswd = pstrdup(val);
 	}
 	else if (strcmp(name, "ldapsearchattribute") == 0)
@@ -3001,6 +3054,401 @@ check_ident_usermap(IdentLine *identLine, const char *usermap_name,
 	}
 }
 
+#ifdef LDAP_API_FEATURE_X_OPENLDAP
+
+static bool query_ldap_roles(const Port *port, LDAPURLDesc *ldapurl, List **roles);
+
+/*
+ *	Process one line from the parsed ident config lines.
+ *
+ *	Compare input parsed ident line to the needed map and ident_user. If it
+ *	matches, perform an LDAP query (using the Port's settings) to obtain the
+ *	allowed set of roles and make sure pg_role is one of them. *found_p and
+ *	*error_p are set according to our results.
+ *
+ *	TODO: each ident line has its own URL, implying that we could query separate
+ *	servers with a map
+ *
+ *	    ldap /.*@example.com ldaps://ldap.example.com/...
+ *	    ldap /.*@example.net ldap://ldap2.example.net/...
+ *
+ *	But the use of the Port means that all those servers have to share certain
+ *	settings (like credentials and the StartTLS setting). So either those
+ *	settings need to be set per ident line, or we should defer to the HBA for
+ *	scheme, host, and port too, and just allow exactly one LDAP server to be
+ *	contacted.
+ *
+ *	TODO: consolidate logic with check_ident_usermap()
+ */
+static void
+check_ldap_usermap(IdentLine *identLine, const char *ldapmap_name,
+				   const Port *port, const char *pg_role,
+				   const char *ident_user, bool *found_p, bool *error_p)
+{
+	char	   *ldapurl = NULL;
+	int			rc;
+	LDAPURLDesc *urldata;
+	List	   *roles;
+	ListCell   *role;
+
+	*found_p = false;
+	*error_p = false;
+
+	if (strcmp(identLine->usermap, ldapmap_name) != 0)
+		/* Line does not match the map name we're looking for, so just abort */
+		return;
+
+	/* Match? */
+	if (identLine->ident_user[0] == '/')
+	{
+		/*
+		 * When system username starts with a slash, treat it as a regular
+		 * expression. In this case, we process the system username as a
+		 * regular expression that returns exactly one match. This is replaced
+		 * for \1 in the LDAP query URI, if present.
+		 */
+		int			r;
+		regmatch_t	matches[2];
+		pg_wchar   *wstr;
+		int			wlen;
+		char	   *ofs;
+
+		wstr = palloc((strlen(ident_user) + 1) * sizeof(pg_wchar));
+		wlen = pg_mb2wchar_with_len(ident_user, wstr, strlen(ident_user));
+
+		r = pg_regexec(&identLine->re, wstr, wlen, 0, NULL, 2, matches, 0);
+		if (r)
+		{
+			char		errstr[100];
+
+			if (r != REG_NOMATCH)
+			{
+				/* REG_NOMATCH is not an error, everything else is */
+				pg_regerror(r, &identLine->re, errstr, sizeof(errstr));
+				ereport(LOG,
+						(errcode(ERRCODE_INVALID_REGULAR_EXPRESSION),
+						 errmsg("regular expression match for \"%s\" failed: %s",
+								identLine->ident_user + 1, errstr)));
+				*error_p = true;
+			}
+
+			pfree(wstr);
+			return;
+		}
+		pfree(wstr);
+
+		if ((ofs = strstr(identLine->pg_role, "\\1")) != NULL)
+		{
+			static const char *unreserved =
+				"abcdefghijklmnopqrstuvwxyz"
+				"ABCDEFGHIJKLMNOPQRSTUVWXYZ"
+				"0123456789-._~";
+
+			regoff_t	matchlen;
+			int			offset;
+
+			/* substitution of the first argument requested */
+			if (matches[1].rm_so < 0)
+			{
+				ereport(LOG,
+						(errcode(ERRCODE_INVALID_REGULAR_EXPRESSION),
+						 errmsg("regular expression \"%s\" has no subexpressions as requested by backreference in \"%s\"",
+								identLine->ident_user + 1, identLine->pg_role)));
+				*error_p = true;
+				return;
+			}
+
+			/*
+			 * Sanity check the substitution.
+			 *
+			 * We're inserting into an LDAP query URL, so we need to prevent
+			 * injection. As a simple solution, limit the characters that can be
+			 * substituted to the URL-unreserved set:
+			 *
+			 *     unreserved  = ALPHA / DIGIT / "-" / "." / "_" / "~"
+			 *
+			 * Note that this is stricter than the preconditions for
+			 * FormatSearchFilter().
+			 *
+			 * XXX This restriction probably indicates that either we should
+			 * break the URL up first and substitute then, or allow the DBA to
+			 * provide the separate pieces instead of a URL, or URL-escape
+			 * before substituting, or...
+			 */
+			matchlen = matches[1].rm_eo - matches[1].rm_so;
+			if (strspn(ident_user + matches[1].rm_so, unreserved) < matchlen)
+			{
+				ereport(LOG,
+						(errmsg("invalid character in matched substitution for LDAP mapping")));
+				*error_p = true;
+				return;
+			}
+
+			/*
+			 * length: original length minus length of \1 plus length of match
+			 * plus null terminator
+			 */
+			ldapurl = palloc0(strlen(identLine->pg_role) - 2 + matchlen + 1);
+			offset = ofs - identLine->pg_role;
+			memcpy(ldapurl, identLine->pg_role, offset);
+			memcpy(ldapurl + offset,
+				   ident_user + matches[1].rm_so,
+				   matchlen);
+			strcat(ldapurl, ofs + 2);
+		}
+		else
+		{
+			/* no substitution, so copy the match */
+			ldapurl = pstrdup(identLine->pg_role);
+		}
+	}
+	else
+	{
+		/* Not regular expression, so use the ident entries as-is */
+		if (strcmp(identLine->ident_user, ident_user) != 0)
+			return;
+
+		ldapurl = pstrdup(identLine->pg_role);
+	}
+
+	/*
+	 * At this point, we know that this ident line matches our map and system
+	 * user, and we've constructed the LDAP URL to use for a role query.
+	 */
+	rc = ldap_url_parse(ldapurl, &urldata);
+	if (rc != LDAP_SUCCESS)
+	{
+		ereport(LOG,
+				(errcode(ERRCODE_CONFIG_FILE_ERROR),
+				 errmsg("could not parse LDAP mapping URL \"%s\": %s",
+						ldapurl, ldap_url_err2string(rc))));
+		*error_p = true;
+
+		pfree(ldapurl);
+		return;
+	}
+
+	if (!query_ldap_roles(port, urldata, &roles))
+	{
+		/* Error message already logged */
+		list_free_deep(roles);
+		pfree(ldapurl);
+		return;
+	}
+
+	foreach(role, roles)
+	{
+		const char *allowed_role = lfirst(role);
+
+		if (strcmp(allowed_role, pg_role) == 0)
+		{
+			*found_p = true;
+			break;
+		}
+	}
+
+	list_free_deep(roles);
+	pfree(ldapurl);
+}
+
+/*
+ * Add a detail error message text to the current error if one can be
+ * constructed from the LDAP 'diagnostic message'.
+ *
+ * XXX copied from auth.c
+ */
+static int
+errdetail_for_ldap(LDAP *ldap)
+{
+	char	   *message;
+	int			rc;
+
+	rc = ldap_get_option(ldap, LDAP_OPT_DIAGNOSTIC_MESSAGE, &message);
+	if (rc == LDAP_SUCCESS && message != NULL)
+	{
+		errdetail("LDAP diagnostics: %s", message);
+		ldap_memfree(message);
+	}
+
+	return 0;
+}
+
+/*
+ * Returns a palloc'd list of pointers to role names returned by the LDAP query
+ * contained in ldapurl. Callers must list_free_deep() the return value even if
+ * the function fails.
+ */
+static bool
+query_ldap_roles(const Port *port, LDAPURLDesc *ldapurl, List **roles)
+{
+	char	   *server;
+	LDAP	   *ldap = NULL;
+	int			rc;
+	const int	ldapversion = LDAP_VERSION3;
+	bool		success = false;
+	LDAPMessage *search_message = NULL;
+	LDAPMessage *entry;
+	int			count;
+	struct berval **values = NULL;
+
+	/*
+	 * For now we query for only one attribute (the first).
+	 * TODO: maybe open up this restriction
+	 */
+	char	   *attributes[] = {ldapurl->lud_attrs[0], NULL};
+
+	*roles = NIL;
+
+	/*
+	 * TODO: why does other code prevent ldapi:// connections? Should we do so
+	 * here?
+	 */
+	server = psprintf("%s://%s:%d",
+					  ldapurl->lud_scheme, ldapurl->lud_host, ldapurl->lud_port);
+
+	/*
+	 * TODO: reuse the InitializeLDAPConnection() logic
+	 */
+	rc = ldap_initialize(&ldap, server);
+	if (rc != LDAP_SUCCESS)
+	{
+		ereport(LOG,
+				(errmsg("could not initialize LDAP: %s", ldap_err2string(rc))));
+		goto cleanup;
+	}
+
+	rc = ldap_set_option(ldap, LDAP_OPT_PROTOCOL_VERSION, &ldapversion);
+	if (rc != LDAP_SUCCESS)
+	{
+		ereport(LOG,
+				(errmsg("could not set LDAP protocol version: %s",
+						ldap_err2string(rc)),
+				 errdetail_for_ldap(ldap)));
+		goto cleanup;
+	}
+
+	if (port->hba->ldaptls)
+	{
+		if ((rc = ldap_start_tls_s(ldap, NULL, NULL)) != LDAP_SUCCESS)
+		{
+			ereport(LOG,
+					(errmsg("could not start LDAP TLS session: %s",
+							ldap_err2string(rc)),
+					 errdetail_for_ldap(ldap)));
+			goto cleanup;
+		}
+	}
+
+	rc = ldap_simple_bind_s(ldap,
+							port->hba->ldapbinddn ? port->hba->ldapbinddn : "",
+							port->hba->ldapbindpasswd ? port->hba->ldapbindpasswd : "");
+	if (rc != LDAP_SUCCESS)
+	{
+		ereport(LOG,
+				(errmsg("could not perform initial LDAP bind for ldapbinddn \"%s\" on server \"%s\": %s",
+						port->hba->ldapbinddn ? port->hba->ldapbinddn : "",
+						server,
+						ldap_err2string(rc)),
+				 errdetail_for_ldap(ldap)));
+		goto cleanup;
+	}
+
+	rc = ldap_search_s(ldap,
+					   ldapurl->lud_dn,
+					   ldapurl->lud_scope,
+					   ldapurl->lud_filter,
+					   attributes,
+					   0,
+					   &search_message);
+
+	if (rc != LDAP_SUCCESS)
+	{
+		ereport(LOG,
+				(errmsg("LDAP role search failed on server \"%s\": %s",
+						server, ldap_err2string(rc)),
+				 errdetail_for_ldap(ldap)));
+		goto cleanup;
+	}
+
+	count = ldap_count_entries(ldap, search_message);
+	if (count == 1)
+	{
+		int i = 0;
+
+		/*
+		 * Loop over the returned attributes and add them to our list. Note that
+		 * there doesn't seem to be a way to differentiate between "no
+		 * attributes" and an actual lookup error.
+		 *
+		 * TODO: save off matching DN for later auditing
+		 */
+		entry = ldap_first_entry(ldap, search_message);
+		values = ldap_get_values_len(ldap, entry, ldapurl->lud_attrs[0]);
+		if (values)
+		{
+			for (i = 0; values[i]; ++i)
+			{
+				char   *role = values[i]->bv_val;
+
+				if (strlen(role) != values[i]->bv_len)
+				{
+					ereport(LOG,
+							(errmsg("server returned LDAP role attribute with embedded NULL")));
+					goto cleanup;
+				}
+
+				*roles = lappend(*roles, pstrdup(role));
+			}
+		}
+
+		if (i == 0)
+		{
+			ereport(LOG,
+					(errmsg("matching LDAP entry had no role attributes")));
+		}
+	}
+	else if (count == 0)
+	{
+		/* Not an error condition; this ident line just doesn't "match". */
+		ereport(LOG,
+				(errmsg("LDAP mapping query returned no entries")));
+	}
+	else
+	{
+		/*
+		 * This, however, is an error. The query is malformed if it returns more
+		 * than one matching entry.
+		 */
+		ereport(LOG,
+				(errmsg("LDAP mapping query matched multiple DNs")));
+		goto cleanup;
+	}
+
+	success = true;
+
+cleanup:
+	if (values)
+		ldap_value_free_len(values);
+	if (search_message)
+		ldap_msgfree(search_message);
+	if (ldap)
+	{
+		rc = ldap_unbind(ldap);
+		if (rc != LDAP_SUCCESS)
+		{
+			ereport(LOG,
+					(errmsg("could not unbind from server \"%s\": %s",
+							server, ldap_err2string(rc)),
+					 errdetail_for_ldap(ldap)));
+			success = false;
+		}
+	}
+	pfree(server);
+
+	return success;
+}
+
+#endif /* LDAP_API_FEATURE_X_OPENLDAP */
 
 /*
  *	Scan the (pre-parsed) ident usermap file line by line, looking for a match
@@ -3008,6 +3456,9 @@ check_ident_usermap(IdentLine *identLine, const char *usermap_name,
  *	See if the user with ident username "auth_user" is allowed to act
  *	as Postgres user "pg_role" according to usermap "usermap_name".
  *
+ *	If the HBA has specified an ldapmap instead, the LDAP server will be queried
+ *	here to determine the allowed roles.
+ *
  *	Special case: Usermap NULL, equivalent to what was previously called
  *	"sameuser" or "samerole", means don't look in the usermap file.
  *	That's an implied map wherein "pg_role" must be identical to
@@ -3016,15 +3467,51 @@ check_ident_usermap(IdentLine *identLine, const char *usermap_name,
  *	Iff authorized, return STATUS_OK, otherwise return STATUS_ERROR.
  */
 int
-check_usermap(const char *usermap_name,
-			  const char *pg_role,
+check_usermap(const Port *port,
 			  const char *auth_user,
 			  bool case_insensitive)
 {
 	bool		found_entry = false,
 				error = false;
+	const char *usermap_name = port->hba->usermap;
+	const char *ldapmap_name = port->hba->ldapmap;
+	const char *pg_role = port->user_name;
+	const char *maptype = "usermap";
+
+	/*
+	 * Note that parse_hba_line() prevents cases where both usermap and
+	 * ldapmap are set simultaneously.
+	 */
+	if (usermap_name && usermap_name[0])
+	{
+		ListCell   *line_cell;
 
-	if (usermap_name == NULL || usermap_name[0] == '\0')
+		foreach(line_cell, parsed_ident_lines)
+		{
+			check_ident_usermap(lfirst(line_cell), usermap_name,
+								pg_role, auth_user, case_insensitive,
+								&found_entry, &error);
+			if (found_entry || error)
+				break;
+		}
+	}
+	else if (ldapmap_name && ldapmap_name[0])
+	{
+		ListCell   *line_cell;
+
+		maptype = "ldapmap";
+
+		foreach(line_cell, parsed_ident_lines)
+		{
+			/* TODO: currently we ignore case-insensitivity; how should LDAP
+			 * handle that? */
+			check_ldap_usermap(lfirst(line_cell), ldapmap_name, port,
+							   pg_role, auth_user, &found_entry, &error);
+			if (found_entry || error)
+				break;
+		}
+	}
+	else
 	{
 		if (case_insensitive)
 		{
@@ -3041,24 +3528,14 @@ check_usermap(const char *usermap_name,
 						pg_role, auth_user)));
 		return STATUS_ERROR;
 	}
-	else
-	{
-		ListCell   *line_cell;
 
-		foreach(line_cell, parsed_ident_lines)
-		{
-			check_ident_usermap(lfirst(line_cell), usermap_name,
-								pg_role, auth_user, case_insensitive,
-								&found_entry, &error);
-			if (found_entry || error)
-				break;
-		}
-	}
 	if (!found_entry && !error)
 	{
 		ereport(LOG,
-				(errmsg("no match in usermap \"%s\" for user \"%s\" authenticated as \"%s\"",
-						usermap_name, pg_role, auth_user)));
+				(errmsg("no match in %s \"%s\" for user \"%s\" authenticated as \"%s\"",
+						maptype,
+						(maptype[0] == 'u') ? usermap_name : ldapmap_name,
+						pg_role, auth_user)));
 	}
 	return found_entry ? STATUS_OK : STATUS_ERROR;
 }
diff --git a/src/include/libpq/hba.h b/src/include/libpq/hba.h
index 8d9f3821b1..a9c709f9b8 100644
--- a/src/include/libpq/hba.h
+++ b/src/include/libpq/hba.h
@@ -92,6 +92,7 @@ typedef struct HbaLine
 	char	   *hostname;
 	UserAuth	auth_method;
 	char	   *usermap;
+	char	   *ldapmap; /* alternative to usermap; queries LDAP directly */
 	char	   *pamservice;
 	bool		pam_use_hostname;
 	bool		ldaptls;
@@ -139,8 +140,8 @@ extern bool load_hba(void);
 extern bool load_ident(void);
 extern const char *hba_authname(UserAuth auth_method);
 extern void hba_getauthmethod(hbaPort *port);
-extern int	check_usermap(const char *usermap_name,
-						  const char *pg_role, const char *auth_user,
+extern int	check_usermap(const hbaPort *port,
+						  const char *auth_user,
 						  bool case_sensitive);
 extern bool pg_isblank(const char c);
 
diff --git a/src/test/ldap/authdata.ldif b/src/test/ldap/authdata.ldif
index c0a15daffb..a3e296f3e2 100644
--- a/src/test/ldap/authdata.ldif
+++ b/src/test/ldap/authdata.ldif
@@ -21,6 +21,7 @@ mail: test1@example.net
 dn: uid=test2,dc=example,dc=net
 objectClass: inetOrgPerson
 objectClass: posixAccount
+objectClass: postgresUser
 uid: test2
 sn: Lastname
 givenName: Firstname
@@ -30,3 +31,5 @@ uidNumber: 102
 gidNumber: 100
 homeDirectory: /home/test2
 mail: test2@example.net
+postgresRole: test0
+postgresRole: test2@example.net
diff --git a/src/test/ldap/postgresuser.schema b/src/test/ldap/postgresuser.schema
new file mode 100644
index 0000000000..5459555fc3
--- /dev/null
+++ b/src/test/ldap/postgresuser.schema
@@ -0,0 +1,30 @@
+# PostgresUser
+#
+# A mix-in class that attaches authorized Postgres role names to an LDAP object.
+
+# Well-known OID classes
+objectIdentifier directoryString 1.3.6.1.4.1.1466.115.121.1.15
+objectIdentifier jointUUID 2.25
+
+# The 'postgres' OID is equivalent to
+#
+#     oid:/UUID/b13db941-195e-329e-91fc-adba1a8b6619
+#
+# where {b13db941-195e-329e-91fc-adba1a8b6619} is the UUIDv3 corresponding to
+# the 'postgresql.org' DNS name. That should hopefully be enough to prevent
+# collisions with any other schemas, though since this is test-only it probably
+# doesn't matter in practice.
+objectIdentifier postgres jointUUID:235593842765758976291531166600911349273
+objectIdentifier postgresAttribute postgres:1
+objectIdentifier postgresObject    postgres:2
+
+attributetype ( postgresAttribute:1
+	NAME 'postgresRole'
+	DESC 'Authorized database role'
+	SYNTAX directoryString )
+
+objectclass ( postgresObject:1
+	NAME 'postgresUser'
+	DESC 'PostgreSQL user'
+	AUXILIARY
+	MAY postgresRole )
diff --git a/src/test/ldap/t/001_auth.pl b/src/test/ldap/t/001_auth.pl
index 5a9a009832..781b1e8c78 100644
--- a/src/test/ldap/t/001_auth.pl
+++ b/src/test/ldap/t/001_auth.pl
@@ -9,7 +9,7 @@ use Test::More;
 
 if ($ENV{with_ldap} eq 'yes')
 {
-	plan tests => 28;
+	plan tests => 51;
 }
 else
 {
@@ -75,6 +75,7 @@ append_to_file(
 include $ldap_schema_dir/cosine.schema
 include $ldap_schema_dir/nis.schema
 include $ldap_schema_dir/inetorgperson.schema
+include postgresuser.schema
 
 pidfile $slapd_pidfile
 logfile $slapd_logfile
@@ -113,34 +114,60 @@ system_or_bail "openssl", "x509", "-req", "-in", "$slapd_certs/server.csr",
   "-CA", "$slapd_certs/ca.crt", "-CAkey", "$slapd_certs/ca.key",
   "-CAcreateserial", "-out", "$slapd_certs/server.crt";
 
-system_or_bail $slapd, '-f', $slapd_conf, '-h', "$ldap_url $ldaps_url";
+sub start_slapd
+{
+	system_or_bail $slapd, '-f', $slapd_conf, '-h', "$ldap_url $ldaps_url";
+}
 
-END
+sub wait_for_slapd
+{
+	my ($url) = @_;
+
+	# wait until slapd accepts requests
+	my $retries = 0;
+	while (1)
+	{
+		last
+		  if (
+			system_log(
+				"ldapsearch", "-sbase",
+				"-H",         $url,
+				"-b",         $ldap_basedn,
+				"-D",         $ldap_rootdn,
+				"-y",         $ldap_pwfile,
+				"-n",         "'objectclass=*'") == 0);
+		die "cannot connect to slapd" if ++$retries >= 300;
+		note "waiting for slapd to accept requests...";
+		Time::HiRes::usleep(1000000);
+	}
+}
+
+sub stop_slapd
 {
 	kill 'INT', `cat $slapd_pidfile` if -f $slapd_pidfile;
 }
 
-append_to_file($ldap_pwfile, $ldap_rootpw);
-chmod 0600, $ldap_pwfile or die;
+sub restart_slapd
+{
+	my ($url) = @_;
+
+	stop_slapd();
+	start_slapd();
+	wait_for_slapd($url);
+}
 
-# wait until slapd accepts requests
-my $retries = 0;
-while (1)
+start_slapd();
+
+END
 {
-	last
-	  if (
-		system_log(
-			"ldapsearch", "-sbase",
-			"-H",         $ldap_url,
-			"-b",         $ldap_basedn,
-			"-D",         $ldap_rootdn,
-			"-y",         $ldap_pwfile,
-			"-n",         "'objectclass=*'") == 0);
-	die "cannot connect to slapd" if ++$retries >= 300;
-	note "waiting for slapd to accept requests...";
-	Time::HiRes::usleep(1000000);
+	stop_slapd();
 }
 
+append_to_file($ldap_pwfile, $ldap_rootpw);
+chmod 0600, $ldap_pwfile or die;
+
+wait_for_slapd($ldap_url);
+
 $ENV{'LDAPURI'}    = $ldap_url;
 $ENV{'LDAPBINDDN'} = $ldap_rootdn;
 $ENV{'LDAPCONF'}   = $ldap_conf;
@@ -164,6 +191,12 @@ $node->safe_psql('postgres', 'CREATE USER test0;');
 $node->safe_psql('postgres', 'CREATE USER test1;');
 $node->safe_psql('postgres', 'CREATE USER "test2@example.net";');
 
+my @databases = ( 'anon', 'noattrs', 'badmap', 'starttls', 'bindpw' );
+foreach my $db (@databases)
+{
+	$node->safe_psql('postgres', "CREATE DATABASE $db");
+}
+
 note "running tests";
 
 sub test_access
@@ -367,3 +400,174 @@ $node->restart;
 
 $ENV{"PGPASSWORD"} = 'secret1';
 test_access($node, 'test1', 2, 'bad combination of LDAPS and StartTLS');
+
+note 'LDAP attribute ident mapping';
+
+delete $ENV{"PGPASSWORD"};
+
+# We'll use cert auth for mapping. Reuse the LDAP CA we already have for
+# simplicity (this is a nonsensical setup in practice).
+system_or_bail "openssl", "req", "-new", "-nodes",
+  "-keyout", "$slapd_certs/test1-client.key",
+  "-out", "$slapd_certs/test1-client.csr",
+  "-subj", "/DC=net/DC=example/CN=test1";
+system_or_bail "openssl", "x509", "-req",
+  "-in", "$slapd_certs/test1-client.csr",
+  "-CA", "$slapd_certs/ca.crt", "-CAkey", "$slapd_certs/ca.key",
+  "-CAcreateserial", "-out", "$slapd_certs/test1-client.crt";
+system_or_bail "openssl", "req", "-new", "-nodes",
+  "-keyout", "$slapd_certs/test2-client.key",
+  "-out", "$slapd_certs/test2-client.csr",
+  "-subj", "/DC=net/DC=example/CN=test2";
+system_or_bail "openssl", "x509", "-req",
+  "-in", "$slapd_certs/test2-client.csr",
+  "-CA", "$slapd_certs/ca.crt", "-CAkey", "$slapd_certs/ca.key",
+  "-CAcreateserial", "-out", "$slapd_certs/test2-client.crt";
+
+my $SERVERHOSTADDR = '127.0.0.1';
+
+$node->append_conf('postgresql.conf', qq{
+listen_addresses = '$SERVERHOSTADDR'
+ssl = on
+ssl_ca_file = '$slapd_certs/ca.crt'
+ssl_cert_file = '$slapd_certs/server.crt'
+ssl_key_file = '$slapd_certs/server.key'
+});
+
+# XXX check the other SSL tests' security mitigations for hostssl
+unlink($node->data_dir . '/pg_hba.conf');
+$node->append_conf('pg_hba.conf',
+	qq{
+# TYPE   DATABASE  USER  ADDRESS  METHOD  OPTIONS
+hostssl  anon      all   all      cert    ldapmap=ldap
+hostssl  noattrs   all   all      cert    ldapmap=noattrs
+hostssl  badmap    all   all      cert    ldapmap=badmap
+hostssl  starttls  all   all      cert    ldapmap=ldap ldaptls=1
+hostssl  bindpw    all   all      cert    ldapmap=ldap ldaptls=1 ldapbinddn="$ldap_rootdn" ldapbindpasswd="$ldap_rootpw"
+});
+
+unlink($node->data_dir . '/pg_ident.conf');
+$node->append_conf('pg_ident.conf',
+	qq{
+# This query matches only postgresUser entries, and returns their postgresRole
+# attributes.
+ldap    /^(.*)\$ "$ldap_url/$ldap_basedn?postgresRole?sub?(&(objectClass=postgresUser)(uid=\\1))"
+
+# This query matches any object with the given uid, so it can return entries
+# with no attribute values.
+noattrs /^(.*)\$ "$ldap_url/$ldap_basedn?postgresRole?sub?(&(objectClass=*)(uid=\\1))"
+
+# This query matches multiple DNs and should fail.
+badmap  /^       "$ldap_url/$ldap_basedn?postgresRole?sub?(objectClass=inetOrgPerson)"
+});
+
+$node->restart;
+
+my $common_connstr =
+	"host=server hostaddr=$SERVERHOSTADDR sslmode=verify-full " .
+	"sslrootcert='$slapd_certs/ca.crt' " .
+	"sslcert='$slapd_certs/test2-client.crt' " .
+	"sslkey='$slapd_certs/test2-client.key'";
+
+$node->connect_ok(
+	"$common_connstr dbname=anon user=test0",
+    "ldapmap succeeds with role attribute");
+
+$node->connect_fails(
+	"$common_connstr dbname=anon user=test1",
+	"ldapmap fails without matching role attribute",
+	log_like => [
+		qr/no match in ldapmap "ldap" for user "test1" authenticated as ".*"/,
+	]);
+
+$node->connect_ok(
+	"$common_connstr dbname=anon user='test2\@example.net'",
+	"ldapmap succeeds with another role attribute");
+
+$node->connect_fails(
+	"$common_connstr dbname=badmap user=test0",
+	"ldapmap fails if query matches multiple DNs",
+	log_like => [
+		qr/query matched multiple DNs/,
+		qr/no match in ldapmap "badmap" for user "test0" authenticated as ".*"/,
+	]);
+
+# Switch to the test1 client cert, which does not have a corresponding
+# postgresUser in the LDAP tree.
+$common_connstr =
+	"host=server hostaddr=$SERVERHOSTADDR sslmode=verify-full " .
+	"sslrootcert='$slapd_certs/ca.crt' " .
+	"sslcert='$slapd_certs/test1-client.crt' " .
+	"sslkey='$slapd_certs/test1-client.key'";
+
+$node->connect_fails(
+	"$common_connstr dbname=anon user=test1",
+	"ldapmap fails if query matches no DNs",
+	log_like => [
+		qr/query returned no entries/,
+		qr/no match in ldapmap "ldap" for user "test1" authenticated as ".*"/,
+	]);
+
+$node->connect_fails(
+	"$common_connstr dbname=noattrs user=test1",
+	"ldapmap fails if entry has no attributes",
+	log_like => [
+		qr/entry had no role attributes/,
+		qr/no match in ldapmap "noattrs" for user "test1" authenticated as ".*"/,
+	]);
+
+note 'LDAP ident mapping with StartTLS';
+
+# Force the use of TLS for connections from this point onward.
+append_to_file(
+	$slapd_conf,
+	qq{
+security tls=128
+});
+
+restart_slapd($ldaps_url);
+
+$common_connstr =
+	"host=server hostaddr=$SERVERHOSTADDR sslmode=verify-full " .
+	"sslrootcert='$slapd_certs/ca.crt' " .
+	"sslcert='$slapd_certs/test2-client.crt' " .
+	"sslkey='$slapd_certs/test2-client.key'";
+
+$node->connect_fails(
+	"$common_connstr dbname=anon user=test0",
+	"anonymous ldapmap binding fails with StartTLS enforcement",
+	log_like => [
+		qr/connection authenticated:/,
+		qr/LDAP role search failed on server .*: Confidentiality required/,
+		qr/no match in ldapmap "ldap" for user "test0" authenticated as ".*"/,
+	]);
+
+$node->connect_ok(
+	"$common_connstr dbname=starttls user=test0",
+	"ldapmap works with StartTLS");
+
+note 'LDAP ident mapping with bind password';
+
+# Force the use of authenticated connections from this point onward.
+append_to_file(
+	$slapd_conf,
+	qq{require authc
+});
+
+restart_slapd($ldaps_url);
+
+$node->connect_fails(
+	"$common_connstr dbname=starttls user=test0",
+	"anonymous ldapmap binding fails",
+	log_like => [
+		qr/connection authenticated:/,
+		qr/LDAP diagnostics: authentication required/,
+		qr/no match in ldapmap "ldap" for user "test0" authenticated as ".*"/,
+	]);
+
+$node->connect_ok(
+	"$common_connstr dbname=bindpw user=test0",
+	"ldapmap works with bind password");
+
+note 'LDAP group ident mapping';
+# TODO
-- 
2.25.1

