Hi all,

In keeping with my theme of expanding the authentication/authorization
options for the server, attached is an experimental patchset that lets
Postgres determine an authenticated user's allowed roles by querying an
LDAP server, and enables SASL binding for those queries.

This lets you delegate pieces of pg_ident.conf to a central server, so
that you don't have to run any synchronization scripts (or deal with
associated staleness problems, repeated load on the LDAP deployment,
etc.). And it lets you make those queries with a client certificate
instead of a bind password, or at the very least protect your bind
password with some SCRAM crypto. You don't have to use the LDAP auth
method for this to work; you can combine it with Kerberos or certs or
any auth method that already supports pg_ident.

The target users, in my mind, are admins who are already using an auth
method with user maps, but have many deployments and want easier
control over granting and revoking database access from one location.
This won't help you so much if you need to have exactly one role per
user -- there's no logic to automatically create roles, so it can't
fully replace the existing synchronization scripts that are out there.
But if all you need is "X, Y, and Z are allowed to log in as guest, and
A and B may connect as admins", then this is meant to simplify your
life.

This is a smaller step than my previous proof-of-concept, which handled
fully federated authentication and authorization via an OAuth provider
[1], and it should be a nice companion to my patch that adds user
mappings to the LDAP auth method [2], though I haven't tried them
together yet. (I've also been thinking about pulling group membership
information out of Kerberos authorization data, for those of you using
Active Directory. Things for later.)

= How-To =

If you want to try it out -- on a non-production system please -- take
a look at the test suite in src/test/ldap, which has been filled out
with some example usage. The core features are the "ldapmap" HBA option
(which you would use instead of "map" in your existing HBA) and the
"ldapsaslmechs" HBA option, which you can set to a list of SASL
mechanisms that you will accept. (The list of supported mechanisms is
determined by both systems' LDAP and SASL libraries, not by Postgres.)

The tricky part is writing the pg_ident line correctly, because it's
currently not a very good user experience. The query is in the form of
an LDAP URL. It needs to return exactly one entry for the user being
authorized; the attribute values contained in that entry will be
interpreted as the list of roles that the user is allowed to connect
as. Regex matching and substitution are supported as they are for
regular maps. Here's a sample:

pg_ident.conf:

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

pg_hba.conf:

  hostssl all all all cert ldapmap=myldapmap ldaptls=1 
ldapsaslmechs=scram-sha-1 ldapbinddn=admin ldapbindpasswd=secret

This particular setup can be described as follows:

- Clients must use client certificates to authenticate to Postgres.
- Once the certificate is verified, Postgres will connect to the LDAP
server at example.com, issue StartTLS, and begin a SCRAM-SHA-1 exchange
using the bind username and password (admin/secret).
- Once that completes, Postgres will issue a query for the LDAP user
that has a uid matching the CN of the client certificate. (If more than
one user matches, authorization fails.)
- The client's PGUSER will be compared with the list of postgresRole
attributes belonging to that LDAP user, and if one matches,
authorization succeeds.

= Areas for Improvement =

I think it would be nice to support LDAP group membership in addition
to object attributes.

Settings for the LDAP connection are currently spread between pg_hba,
pg_ident, and environment variables like LDAPTLS_CERT. I made the
situation worse by allowing the pg_ident query to contain a scheme,
host, and port. That makes it seem like you could send different users
to different LDAP servers, but since they would all have to share
exactly the same TLS settings anyway, I think this was a mistake on my
part.

That mistake aside, I think the current URL query syntax is powerful
but unintuitive. I would rather see that as an option for power users,
and let other people just specify the user filter and role attribute
separately. And there needs to be more logging around the feature, to
help debug problems.

Regex substitution of user-controlled data into an LDAP query is
perilous, and I don't like it. For now I have restricted the allowed
characters as a first mitigation.

Is it safe to use listen_addresses in the test suite, as I have done,
as long as the HBA requires authentication? Or is that reopening a
security hole? I seem to recall discussion on this but my search-fu has
failed me.

There's a lot of code duplication in the current patchset that would
need to be undone.

...and more; see TODOs in the patches if you're interested.

= Patch Roadmap =

- 0001 fixes error messages that are printed when ldap_url_parse()
fails. Since the pg_ident queries use LDAP URLs, and it's easy to get
them wrong, that fix is particularly important for this patchset. But I
think it could potentially be applied separately.

- 0002 implements the "ldapmap" HBA option and enables the ldaptls,
ldapbinddn, and ldapbindpasswd options for it. It also adds
corresponding tests to the LDAP suite.

- 0003 tests the use of client certificates via LDAP environment
variables. (This is already supported today but I didn't see any
coverage, which will be important for the last patch.)

- 0004 implements the "ldapsaslmechs" HBA option and adds enough SASL
support for at least the EXTERNAL and SCRAM-* mechanisms. Others may
work but I haven't tested them. This feature is available only if you
have the <sasl/sasl.h> header on your system at build time.

WDYT? (My responses here will be slower than usual. Hope you all have a
great end to the year!)

--Jacob

[1] 
https://www.postgresql.org/message-id/flat/d1b467a78e0e36ed85a09adf979d04cf124a9d4b.ca...@vmware.com
[2] 
https://www.postgresql.org/message-id/flat/1a61806047c536e7528b943d0cfe12608118ca31.ca...@vmware.com
From 2a88b8cbdbe790df55240850995852e8f2d304eb Mon Sep 17 00:00:00 2001
From: Jacob Champion <pchamp...@vmware.com>
Date: Thu, 2 Dec 2021 09:37:08 -0800
Subject: [PATCH 1/4] hba: correct messages when ldap_url_parse() fails

ldap_err2string() doesn't work for the return value of ldap_url_parse();
you end up with strange messages like

    LOG: could not parse LDAP URL "<bad-url>": Time limit exceeded

There doesn't appear to be a corresponding error-to-string function for
the URL codes in OpenLDAP, so add a helper.
---
 src/backend/libpq/hba.c | 49 +++++++++++++++++++++++++++++++++++++++--
 1 file changed, 47 insertions(+), 2 deletions(-)

diff --git a/src/backend/libpq/hba.c b/src/backend/libpq/hba.c
index 4328eb74fe..600972e9a4 100644
--- a/src/backend/libpq/hba.c
+++ b/src/backend/libpq/hba.c
@@ -1691,6 +1691,51 @@ parse_hba_line(TokenizedLine *tok_line, int elevel)
 }
 
 
+#ifdef LDAP_API_FEATURE_X_OPENLDAP
+
+/*
+ * OpenLDAP's ldap_err2string() doesn't work on the return value of
+ * ldap_url_parse(). Provide a helper to do so.
+ */
+static const char *
+ldap_url_err2string(int errcode)
+{
+	switch (errcode)
+	{
+		case LDAP_URL_SUCCESS:
+			return "success";
+
+		/* internal/developer errors */
+		case LDAP_URL_ERR_MEM:
+			return "out of memory";
+		case LDAP_URL_ERR_PARAM:
+			return "invalid parameter";
+
+		/* user errors */
+		case LDAP_URL_ERR_BADSCHEME:
+			return "unsupported scheme";
+		case LDAP_URL_ERR_BADENCLOSURE:
+			return "missing closing bracket";
+		case LDAP_URL_ERR_BADURL:
+			return "malformed URL";
+		case LDAP_URL_ERR_BADHOST:
+			return "bad host/port";
+		case LDAP_URL_ERR_BADATTRS:
+			return "bad/missing attributes";
+		case LDAP_URL_ERR_BADSCOPE:
+			return "bad/missing scope";
+		case LDAP_URL_ERR_BADFILTER:
+			return "bad/missing filter";
+		case LDAP_URL_ERR_BADEXTS:
+			return "bad/missing extensions";
+	}
+
+	return psprintf("unknown error: %d", errcode);
+}
+
+#endif /* LDAP_API_FEATURE_X_OPENLDAP */
+
+
 /*
  * Parse one name-value pair as an authentication option into the given
  * HbaLine.  Return true if we successfully parse the option, false if we
@@ -1818,9 +1863,9 @@ parse_hba_auth_opt(char *name, char *val, HbaLine *hbaline,
 		{
 			ereport(elevel,
 					(errcode(ERRCODE_CONFIG_FILE_ERROR),
-					 errmsg("could not parse LDAP URL \"%s\": %s", val, ldap_err2string(rc))));
+					 errmsg("could not parse LDAP URL \"%s\": %s", val, ldap_url_err2string(rc))));
 			*err_msg = psprintf("could not parse LDAP URL \"%s\": %s",
-								val, ldap_err2string(rc));
+								val, ldap_url_err2string(rc));
 			return false;
 		}
 
-- 
2.25.1

From aec6ad94c1640691f70b0f8d1434a64e25a30755 Mon Sep 17 00:00:00 2001
From: Jacob Champion <pchamp...@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: te...@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: te...@example.net
+postgresRole: test0
+postgresRole: te...@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 "te...@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

From 56ffba733b6dea553b56fde4daad8d32266de592 Mon Sep 17 00:00:00 2001
From: Jacob Champion <pchamp...@vmware.com>
Date: Mon, 13 Dec 2021 10:27:08 -0800
Subject: [PATCH 3/4] ldapmap: test binding with a client cert/key

Make sure that ldapmap queries can utilize a client cert. This was
already supported before, but it'll be more important with the next
patch, so test it explicitly.
---
 src/test/ldap/t/001_auth.pl | 42 ++++++++++++++++++++++++++++++++++++-
 1 file changed, 41 insertions(+), 1 deletion(-)

diff --git a/src/test/ldap/t/001_auth.pl b/src/test/ldap/t/001_auth.pl
index 781b1e8c78..6467a6c4af 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 => 51;
+	plan tests => 56;
 }
 else
 {
@@ -79,6 +79,7 @@ include postgresuser.schema
 
 pidfile $slapd_pidfile
 logfile $slapd_logfile
+loglevel conns filter stats
 
 access to *
         by * read
@@ -569,5 +570,44 @@ $node->connect_ok(
 	"$common_connstr dbname=bindpw user=test0",
 	"ldapmap works with bind password");
 
+note 'LDAP ident mapping with client certificate';
+
+# Set up a certificate for the root user.
+system_or_bail "openssl", "req", "-new", "-nodes",
+  "-keyout", "$slapd_certs/root-client.key",
+  "-out", "$slapd_certs/root-client.csr",
+  "-subj", "/DC=net/DC=example/CN=Manager";
+system_or_bail "openssl", "x509", "-req", "-in", "$slapd_certs/root-client.csr",
+  "-CA", "$slapd_certs/ca.crt", "-CAkey", "$slapd_certs/ca.key",
+  "-CAcreateserial", "-out", "$slapd_certs/root-client.crt";
+
+$ENV{'LDAPTLS_CERT'} = "$slapd_certs/root-client.crt";
+$ENV{'LDAPTLS_KEY'}  = "$slapd_certs/root-client.key";
+
+# Force the use of client certificates from this point onward.
+append_to_file(
+	$slapd_conf,
+	qq{TLSVerifyClient demand
+});
+
+restart_slapd($ldaps_url);
+
+$node->connect_fails(
+	"$common_connstr dbname=bindpw user=test0",
+	"ldapmap with bind password fails without client certificate",
+	log_like => [
+		qr/connection authenticated:/,
+		qr/could not perform initial LDAP bind for ldapbinddn "cn=Manager,dc=example,dc=net" on server ".*": Can't contact LDAP server/,
+		qr/no match in ldapmap "ldap" for user "test0" authenticated as ".*"/,
+	]);
+
+# The server needs to be restarted to pick up all the above LDAPTLS_* settings
+# from the environment.
+$node->restart;
+
+$node->connect_ok(
+	"$common_connstr dbname=bindpw user=test0",
+	"ldapmap works with bind certificate");
+
 note 'LDAP group ident mapping';
 # TODO
-- 
2.25.1

From 2f3c8959274a53fb6027cd666c5e942c6ab68ed2 Mon Sep 17 00:00:00 2001
From: Jacob Champion <pchamp...@vmware.com>
Date: Mon, 13 Dec 2021 15:39:16 -0800
Subject: [PATCH 4/4] ldapmap: implement SASL binding

Add the "ldapsaslmechs" HBA option, which tells the ldapmap query to use
SASL binding (instead of simple username/password binding) and specifies
the SASL mechanism list that is accepted. This allows the DBA to further
lock down the LDAP connection and even get rid of bind passwords
altogether.

This new feature is gated on the existence of the <sasl/sasl.h> header,
which we need in order to implement the SASL callback API. (No new
runtime dependencies are needed.)

The currently tested mechanisms are

- EXTERNAL, which authenticates using a TLS client certificate, and

- SCRAM-SHA-1, which performs mutual password authentication without
  ever sending the password over the connection. (Other SCRAM-*
  mechanisms should work too but I haven't tested them.)

TODOs:

- This seems like it would be useful for the ldap auth method, too.

- I reuse ldapbinddn for the SASL auth name, but it's probably not
  actually a DN in practice (and it's certainly not a DN for the test
  case I provided). Maybe make a new HBA option?
---
 configure                   |  12 +++
 configure.ac                |   1 +
 src/backend/libpq/hba.c     | 165 +++++++++++++++++++++++++++++++++---
 src/include/libpq/hba.h     |   1 +
 src/include/pg_config.h.in  |   3 +
 src/test/ldap/authdata.ldif |   7 ++
 src/test/ldap/t/001_auth.pl |  35 +++++++-
 7 files changed, 210 insertions(+), 14 deletions(-)

diff --git a/configure b/configure
index 3b19105328..e5ac7f441d 100755
--- a/configure
+++ b/configure
@@ -13993,6 +13993,18 @@ else
   as_fn_error $? "header file <ldap.h> is required for LDAP" "$LINENO" 5
 fi
 
+done
+
+     for ac_header in sasl/sasl.h
+do :
+  ac_fn_c_check_header_mongrel "$LINENO" "sasl/sasl.h" "ac_cv_header_sasl_sasl_h" "$ac_includes_default"
+if test "x$ac_cv_header_sasl_sasl_h" = xyes; then :
+  cat >>confdefs.h <<_ACEOF
+#define HAVE_SASL_SASL_H 1
+_ACEOF
+
+fi
+
 done
 
      { $as_echo "$as_me:${as_lineno-$LINENO}: checking for compatible LDAP implementation" >&5
diff --git a/configure.ac b/configure.ac
index e77d4dcf2d..c1f0048b0f 100644
--- a/configure.ac
+++ b/configure.ac
@@ -1523,6 +1523,7 @@ if test "$with_ldap" = yes ; then
   if test "$PORTNAME" != "win32"; then
      AC_CHECK_HEADERS(ldap.h, [],
                       [AC_MSG_ERROR([header file <ldap.h> is required for LDAP])])
+     AC_CHECK_HEADERS(sasl/sasl.h)
      PGAC_LDAP_SAFE
   else
      AC_CHECK_HEADERS(winldap.h, [],
diff --git a/src/backend/libpq/hba.c b/src/backend/libpq/hba.c
index 57440107be..7516a9681e 100644
--- a/src/backend/libpq/hba.c
+++ b/src/backend/libpq/hba.c
@@ -51,6 +51,9 @@
 #else
 #define LDAP_DEPRECATED 1
 #include <ldap.h>
+#if HAVE_SASL_SASL_H
+#include <sasl/sasl.h> /* header-only dependency for sasl_interact_t */
+#endif
 #endif
 #endif
 
@@ -1712,6 +1715,10 @@ parse_hba_line(TokenizedLine *tok_line, int elevel)
 		{
 			badopt = "ldapbindpasswd";
 		}
+		else if (parsedline->ldapsaslmechs)
+		{
+			badopt = "ldapsaslmechs";
+		}
 
 		if (badopt)
 		{
@@ -2004,6 +2011,10 @@ parse_hba_auth_opt(char *name, char *val, HbaLine *hbaline,
 	{
 		hbaline->ldapbindpasswd = pstrdup(val);
 	}
+	else if (strcmp(name, "ldapsaslmechs") == 0)
+	{
+		hbaline->ldapsaslmechs = pstrdup(val);
+	}
 	else if (strcmp(name, "ldapsearchattribute") == 0)
 	{
 		REQUIRE_AUTH_OPTION(uaLDAP, "ldapsearchattribute", "ldap");
@@ -3273,6 +3284,146 @@ errdetail_for_ldap(LDAP *ldap)
 	return 0;
 }
 
+#if HAVE_SASL_SASL_H
+
+struct sasl_ctx
+{
+	const HbaLine  *hba;
+};
+
+/*
+ * Callback for ldap_sasl_interactive_bind_s(). libsasl asks us for various
+ * authentication parameters, and we fill them in.
+ */
+static int
+sasl_interaction(LDAP *ldap, unsigned flags, void *vctx, void *sasl_interact)
+{
+	struct sasl_ctx *ctx = vctx;
+	sasl_interact_t *prompt = sasl_interact;
+
+	while (true)
+	{
+		switch (prompt->id)
+		{
+			case SASL_CB_LIST_END:
+				return LDAP_SUCCESS;
+
+			case SASL_CB_USER:
+				/*
+				 * This is the authzid; we leave it empty/default.
+				 */
+				prompt->result = prompt->defresult;
+				break;
+
+			case SASL_CB_AUTHNAME:
+				/*
+				 * The username for the authentication; this is our bind DN.
+				 */
+				if (!ctx->hba->ldapbinddn)
+				{
+					ereport(LOG,
+							errmsg("SASL mechanism requires ldapbinddn"));
+					return LDAP_LOCAL_ERROR;
+				}
+
+				prompt->result = ctx->hba->ldapbinddn;
+				prompt->len = strlen(prompt->result);
+
+				break;
+
+			case SASL_CB_PASS:
+				/*
+				 * The password.
+				 */
+				if (!ctx->hba->ldapbindpasswd)
+				{
+					ereport(LOG,
+							errmsg("SASL mechanism requires ldapbindpasswd"));
+					return LDAP_LOCAL_ERROR;
+				}
+
+				prompt->result = ctx->hba->ldapbindpasswd;
+				prompt->len = strlen(prompt->result);
+
+				break;
+
+			default:
+				ereport(LOG,
+						errmsg("SASL interaction type 0x%lX (%s) is unimplemented",
+							   prompt->id, prompt->challenge));
+				return LDAP_LOCAL_ERROR;
+		}
+
+		++prompt;
+	}
+
+	/* unreachable */
+	return LDAP_LOCAL_ERROR;
+}
+
+#endif /* HAVE_SASL_SASL_H */
+
+/*
+ * Performs either a simple or a SASL bind over the LDAP connection, depending
+ * on the HBA settings.
+ */
+static bool
+bind_ldap(const HbaLine *hba, LDAP *ldap, const char *server_name)
+{
+	int			rc;
+#if HAVE_SASL_SASL_H
+	struct sasl_ctx ctx = {0};
+#endif
+
+	if (!(hba->ldapsaslmechs && hba->ldapsaslmechs[0]))
+	{
+		/* Use a simple bind. */
+		rc = ldap_simple_bind_s(ldap,
+								hba->ldapbinddn ? hba->ldapbinddn : "",
+								hba->ldapbindpasswd ? hba->ldapbindpasswd : "");
+		if (rc != LDAP_SUCCESS)
+		{
+			ereport(LOG,
+					(errmsg("could not perform initial LDAP bind for ldapbinddn \"%s\" on server \"%s\": %s",
+							hba->ldapbinddn ? hba->ldapbinddn : "",
+							server_name,
+							ldap_err2string(rc)),
+					 errdetail_for_ldap(ldap)));
+		}
+
+		return (rc == LDAP_SUCCESS);
+	}
+
+#if HAVE_SASL_SASL_H
+	/* DBA has asked for a SASL bind. */
+	ctx.hba = hba;
+
+	rc = ldap_sasl_interactive_bind_s(ldap,
+									  NULL, /* DN is ignored for SASL */
+									  hba->ldapsaslmechs,
+									  NULL, NULL, /* server/client controls */
+									  LDAP_SASL_QUIET, /* don't prompt */
+									  sasl_interaction,
+									  &ctx);
+	if (rc != LDAP_SUCCESS)
+	{
+		ereport(LOG,
+				(errmsg("could not perform SASL bind on server \"%s\": %s",
+						server_name,
+						ldap_err2string(rc)),
+				 errdetail_for_ldap(ldap)));
+	}
+
+	return (rc == LDAP_SUCCESS);
+
+#else
+	ereport(LOG,
+			(errmsg("this build does not support LDAP SASL binding")));
+	return false;
+
+#endif /* HAVE_SASL_SASL_H */
+}
+
 /*
  * 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
@@ -3339,19 +3490,9 @@ query_ldap_roles(const Port *port, LDAPURLDesc *ldapurl, List **roles)
 		}
 	}
 
-	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)));
+	/* Bind using our HBA settings. */
+	if (!bind_ldap(port->hba, ldap, server))
 		goto cleanup;
-	}
 
 	rc = ldap_search_s(ldap,
 					   ldapurl->lud_dn,
diff --git a/src/include/libpq/hba.h b/src/include/libpq/hba.h
index a9c709f9b8..c701cb72a7 100644
--- a/src/include/libpq/hba.h
+++ b/src/include/libpq/hba.h
@@ -101,6 +101,7 @@ typedef struct HbaLine
 	int			ldapport;
 	char	   *ldapbinddn;
 	char	   *ldapbindpasswd;
+	char	   *ldapsaslmechs;
 	char	   *ldapsearchattribute;
 	char	   *ldapsearchfilter;
 	char	   *ldapbasedn;
diff --git a/src/include/pg_config.h.in b/src/include/pg_config.h.in
index 7525c16597..5479b2d952 100644
--- a/src/include/pg_config.h.in
+++ b/src/include/pg_config.h.in
@@ -488,6 +488,9 @@
 /* Define to 1 if you have the `rl_variable_bind' function. */
 #undef HAVE_RL_VARIABLE_BIND
 
+/* Define to 1 if you have the <sasl/sasl.h> header file. */
+#undef HAVE_SASL_SASL_H
+
 /* Define to 1 if you have the <security/pam_appl.h> header file. */
 #undef HAVE_SECURITY_PAM_APPL_H
 
diff --git a/src/test/ldap/authdata.ldif b/src/test/ldap/authdata.ldif
index a3e296f3e2..0a32042d85 100644
--- a/src/test/ldap/authdata.ldif
+++ b/src/test/ldap/authdata.ldif
@@ -33,3 +33,10 @@ homeDirectory: /home/test2
 mail: te...@example.net
 postgresRole: test0
 postgresRole: te...@example.net
+
+# For SCRAM auth only; the rootpw should take care of the rest.
+dn: cn=Manager,dc=example,dc=net
+objectClass: inetOrgPerson
+uid: Manager
+sn: Lastname
+cn: Manager
diff --git a/src/test/ldap/t/001_auth.pl b/src/test/ldap/t/001_auth.pl
index 6467a6c4af..c296c746a1 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 => 56;
+	plan tests => 61;
 }
 else
 {
@@ -192,7 +192,7 @@ $node->safe_psql('postgres', 'CREATE USER test0;');
 $node->safe_psql('postgres', 'CREATE USER test1;');
 $node->safe_psql('postgres', 'CREATE USER "te...@example.net";');
 
-my @databases = ( 'anon', 'noattrs', 'badmap', 'starttls', 'bindpw' );
+my @databases = ( 'anon', 'noattrs', 'badmap', 'starttls', 'bindpw', 'bindscram', 'bindcert' );
 foreach my $db (@databases)
 {
 	$node->safe_psql('postgres', "CREATE DATABASE $db");
@@ -445,6 +445,8 @@ 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"
+hostssl  bindscram all   all      cert    ldapmap=ldap ldaptls=1 ldapsaslmechs=scram-sha-1 ldapbinddn=Manager ldapbindpasswd="$ldap_rootpw"
+hostssl  bindcert  all   all      cert    ldapmap=ldap ldaptls=1 ldapsaslmechs=external
 });
 
 unlink($node->data_dir . '/pg_ident.conf');
@@ -570,6 +572,31 @@ $node->connect_ok(
 	"$common_connstr dbname=bindpw user=test0",
 	"ldapmap works with bind password");
 
+note 'LDAP ident mapping with SCRAM binding';
+
+$node->connect_fails(
+	"$common_connstr dbname=bindscram user=test0",
+	"ldapmap can't perform SCRAM authentication without server setup",
+	log_like => [
+		qr/could not perform SASL bind on server .*: Invalid credentials/,
+		qr/user not found: no secret in database/,
+	]);
+
+# Map the SCRAM-specific authentication DN to our root user.
+append_to_file(
+	$slapd_conf,
+	qq{
+authz-regexp
+	uid=Manager,cn=SCRAM-SHA-1,cn=auth
+	cn=Manager,dc=example,dc=net
+});
+
+restart_slapd($ldaps_url);
+
+$node->connect_ok(
+	"$common_connstr dbname=bindscram user=test0",
+	"ldapmap works with SCRAM authentication to LDAP server");
+
 note 'LDAP ident mapping with client certificate';
 
 # Set up a certificate for the root user.
@@ -609,5 +636,9 @@ $node->connect_ok(
 	"$common_connstr dbname=bindpw user=test0",
 	"ldapmap works with bind certificate");
 
+$node->connect_ok(
+	"$common_connstr dbname=bindcert user=test0",
+	"ldapmap works with client certificate authentication");
+
 note 'LDAP group ident mapping';
 # TODO
-- 
2.25.1

Reply via email to