From cdbfe017228657028305429fe62bce1489e63ad6 Mon Sep 17 00:00:00 2001 From: Duane May Date: Wed, 15 Jul 2026 15:19:54 -0400 Subject: [PATCH 1/5] Add failing tests for LDAP external group DN support UAA only ever reports the bare cn of an LDAP group as its "external group" name (used for the externalGroupsWhitelist and /userinfo's roles field), never the full DN. This breaks CF's external-group role-assignment feature when the CF-side mapping is configured with a group's full DN, since it can never match what UAA reports. Adds the includeExternalGroupDn plumbing (config field, accessors, uaa.yml wiring) needed for the fix, plus tests that pin down current default behavior and demonstrate the gap: enabling the flag should add the DN as an additional matchable external-group name, but the extraction logic hasn't been updated yet, so those new assertions fail. The fix lands in a follow-up commit. --- .../LdapIdentityProviderDefinition.java | 16 +++- .../identity/uaa/util/LdapUtils.java | 5 ++ .../LdapLoginAuthenticationManagerTests.java | 84 +++++++++++++++++++ .../LdapIdentityProviderDefinitionTest.java | 39 +++++++++ .../mock/ldap/AbstractLdapMockMvcTest.java | 34 ++++++++ 5 files changed, 177 insertions(+), 1 deletion(-) diff --git a/model/src/main/java/org/cloudfoundry/identity/uaa/provider/LdapIdentityProviderDefinition.java b/model/src/main/java/org/cloudfoundry/identity/uaa/provider/LdapIdentityProviderDefinition.java index 38a6698349d..8cb6f57dc9b 100644 --- a/model/src/main/java/org/cloudfoundry/identity/uaa/provider/LdapIdentityProviderDefinition.java +++ b/model/src/main/java/org/cloudfoundry/identity/uaa/provider/LdapIdentityProviderDefinition.java @@ -49,6 +49,7 @@ public class LdapIdentityProviderDefinition extends ExternalIdentityProviderDefi public static final String LDAP_EMAIL_DOMAIN = LDAP_PREFIX + EMAIL_DOMAIN_ATTR; public static final String LDAP_STORE_CUSTOM_ATTRIBUTES = LDAP_PREFIX + STORE_CUSTOM_ATTRIBUTES_NAME; public static final String LDAP_EXTERNAL_GROUPS_WHITELIST = LDAP_PREFIX + "externalGroupsWhitelist"; + public static final String LDAP_INCLUDE_EXTERNAL_GROUP_DN = LDAP_PREFIX + "includeExternalGroupDn"; public static final String LDAP_GROUP_FILE_GROUPS_AS_SCOPES = "ldap/ldap-groups-as-scopes.xml"; public static final String LDAP_GROUP_FILE_GROUPS_MAP_TO_SCOPES = "ldap/ldap-groups-map-to-scopes.xml"; public static final String LDAP_GROUP_FILE_GROUPS_NULL_XML = "ldap/ldap-groups-null.xml"; @@ -75,7 +76,7 @@ public class LdapIdentityProviderDefinition extends ExternalIdentityProviderDefi List.of("ldap/ldap-groups-as-scopes.xml", "ldap/ldap-groups-map-to-scopes.xml", "ldap/ldap-groups-null.xml", "ldap/ldap-groups-populator.xml"); - public static final List LDAP_PROPERTY_NAMES = List.of(LDAP_ATTRIBUTE_MAPPINGS, LDAP_BASE_LOCAL_PASSWORD_COMPARE, LDAP_BASE_MAIL_ATTRIBUTE_NAME, LDAP_BASE_MAIL_SUBSTITUTE, LDAP_BASE_MAIL_SUBSTITUTE_OVERRIDES_LDAP, LDAP_BASE_PASSWORD, LDAP_BASE_PASSWORD_ATTRIBUTE_NAME, LDAP_BASE_PASSWORD_ENCODER, LDAP_BASE_REFERRAL, LDAP_BASE_SEARCH_BASE, LDAP_BASE_SEARCH_FILTER, LDAP_BASE_URL, LDAP_BASE_USER_DN, LDAP_BASE_USER_DN_PATTERN, LDAP_BASE_USER_DN_PATTERN_DELIMITER, LDAP_EMAIL_DOMAIN, LDAP_EXTERNAL_GROUPS_WHITELIST, LDAP_GROUPS_AUTO_ADD, LDAP_GROUPS_FILE, LDAP_GROUPS_GROUP_ROLE_ATTRIBUTE, LDAP_GROUPS_GROUP_SEARCH_FILTER, LDAP_GROUPS_IGNORE_PARTIAL_RESULT_EXCEPTION, LDAP_GROUPS_MAX_SEARCH_DEPTH, LDAP_GROUPS_SEARCH_BASE, LDAP_GROUPS_SEARCH_SUBTREE, LDAP_PROFILE_FILE, LDAP_SSL_SKIPVERIFICATION, LDAP_SSL_TLS); + public static final List LDAP_PROPERTY_NAMES = List.of(LDAP_ATTRIBUTE_MAPPINGS, LDAP_BASE_LOCAL_PASSWORD_COMPARE, LDAP_BASE_MAIL_ATTRIBUTE_NAME, LDAP_BASE_MAIL_SUBSTITUTE, LDAP_BASE_MAIL_SUBSTITUTE_OVERRIDES_LDAP, LDAP_BASE_PASSWORD, LDAP_BASE_PASSWORD_ATTRIBUTE_NAME, LDAP_BASE_PASSWORD_ENCODER, LDAP_BASE_REFERRAL, LDAP_BASE_SEARCH_BASE, LDAP_BASE_SEARCH_FILTER, LDAP_BASE_URL, LDAP_BASE_USER_DN, LDAP_BASE_USER_DN_PATTERN, LDAP_BASE_USER_DN_PATTERN_DELIMITER, LDAP_EMAIL_DOMAIN, LDAP_EXTERNAL_GROUPS_WHITELIST, LDAP_INCLUDE_EXTERNAL_GROUP_DN, LDAP_GROUPS_AUTO_ADD, LDAP_GROUPS_FILE, LDAP_GROUPS_GROUP_ROLE_ATTRIBUTE, LDAP_GROUPS_GROUP_SEARCH_FILTER, LDAP_GROUPS_IGNORE_PARTIAL_RESULT_EXCEPTION, LDAP_GROUPS_MAX_SEARCH_DEPTH, LDAP_GROUPS_SEARCH_BASE, LDAP_GROUPS_SEARCH_SUBTREE, LDAP_PROFILE_FILE, LDAP_SSL_SKIPVERIFICATION, LDAP_SSL_TLS); public static final Map> LDAP_PROPERTY_TYPES = new HashMap<>(); @@ -97,6 +98,7 @@ public class LdapIdentityProviderDefinition extends ExternalIdentityProviderDefi LDAP_PROPERTY_TYPES.put(LDAP_BASE_USER_DN_PATTERN_DELIMITER, String.class); LDAP_PROPERTY_TYPES.put(LDAP_EMAIL_DOMAIN, List.class); LDAP_PROPERTY_TYPES.put(LDAP_EXTERNAL_GROUPS_WHITELIST, List.class); + LDAP_PROPERTY_TYPES.put(LDAP_INCLUDE_EXTERNAL_GROUP_DN, Boolean.class); LDAP_PROPERTY_TYPES.put(LDAP_GROUPS_AUTO_ADD, Boolean.class); LDAP_PROPERTY_TYPES.put(LDAP_GROUPS_FILE, String.class); LDAP_PROPERTY_TYPES.put(LDAP_GROUPS_GROUP_ROLE_ATTRIBUTE, String.class); @@ -138,6 +140,7 @@ public class LdapIdentityProviderDefinition extends ExternalIdentityProviderDefi private Boolean groupSearchSubTree = true; private int maxGroupSearchDepth = 10; private String groupRoleAttribute; + private Boolean includeExternalGroupDn = false; private String tlsConfiguration = LDAP_TLS_NONE; @@ -356,6 +359,14 @@ public void setGroupRoleAttribute(String groupRoleAttribute) { this.groupRoleAttribute = groupRoleAttribute; } + public Boolean isIncludeExternalGroupDn() { + return includeExternalGroupDn; + } + + public void setIncludeExternalGroupDn(Boolean includeExternalGroupDn) { + this.includeExternalGroupDn = includeExternalGroupDn; + } + @JsonIgnore public Boolean isConfigured() { return StringUtils.hasText(getBaseUrl()); @@ -485,6 +496,9 @@ public boolean equals(Object o) { if (!Objects.equals(groupSearchSubTree, that.groupSearchSubTree)) { return false; } + if (!Objects.equals(includeExternalGroupDn, that.includeExternalGroupDn)) { + return false; + } return Objects.equals(groupRoleAttribute, that.groupRoleAttribute); } diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/util/LdapUtils.java b/server/src/main/java/org/cloudfoundry/identity/uaa/util/LdapUtils.java index 660cd0efdcf..7e14fe8addf 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/util/LdapUtils.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/util/LdapUtils.java @@ -55,6 +55,7 @@ public static ConfigurableEnvironment getLdapConfigurationEnvironment(LdapIdenti setIfNotNull(LdapIdentityProviderDefinition.LDAP_BASE_USER_DN_PATTERN_DELIMITER, definition.getUserDNPatternDelimiter(), properties); setIfNotNull(LdapIdentityProviderDefinition.LDAP_EMAIL_DOMAIN, definition.getEmailDomain(), properties); setIfNotNull(LdapIdentityProviderDefinition.LDAP_EXTERNAL_GROUPS_WHITELIST, definition.getExternalGroupsWhitelist(), properties); + setIfNotNull(LdapIdentityProviderDefinition.LDAP_INCLUDE_EXTERNAL_GROUP_DN, definition.isIncludeExternalGroupDn(), properties); setIfNotNull(LdapIdentityProviderDefinition.LDAP_GROUPS_AUTO_ADD, definition.isAutoAddGroups(), properties); setIfNotNull(LdapIdentityProviderDefinition.LDAP_GROUPS_FILE, definition.getLdapGroupFile(), properties); setIfNotNull(LdapIdentityProviderDefinition.LDAP_GROUPS_GROUP_ROLE_ATTRIBUTE, definition.getGroupRoleAttribute(), properties); @@ -101,6 +102,10 @@ public static LdapIdentityProviderDefinition fromConfig(Map ldap definition.setExternalGroupsWhitelist((List) ldapConfig.get(LdapIdentityProviderDefinition.LDAP_EXTERNAL_GROUPS_WHITELIST)); } + if (ldapConfig.get(LdapIdentityProviderDefinition.LDAP_INCLUDE_EXTERNAL_GROUP_DN) != null) { + definition.setIncludeExternalGroupDn((Boolean) ldapConfig.get(LdapIdentityProviderDefinition.LDAP_INCLUDE_EXTERNAL_GROUP_DN)); + } + if (ldapConfig.get(LdapIdentityProviderDefinition.LDAP_ATTRIBUTE_MAPPINGS) != null) { definition.setAttributeMappings((Map) ldapConfig.get(LdapIdentityProviderDefinition.LDAP_ATTRIBUTE_MAPPINGS)); } diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/authentication/manager/LdapLoginAuthenticationManagerTests.java b/server/src/test/java/org/cloudfoundry/identity/uaa/authentication/manager/LdapLoginAuthenticationManagerTests.java index 8a6f51a78ea..8f8bd87f687 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/authentication/manager/LdapLoginAuthenticationManagerTests.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/authentication/manager/LdapLoginAuthenticationManagerTests.java @@ -9,6 +9,7 @@ import org.cloudfoundry.identity.uaa.provider.LdapIdentityProviderDefinition; import org.cloudfoundry.identity.uaa.provider.ldap.extension.ExtendedLdapUserImpl; import org.cloudfoundry.identity.uaa.provider.ldap.extension.LdapAuthority; +import org.cloudfoundry.identity.uaa.provider.ldap.extension.SpringSecurityLdapTemplate; import org.cloudfoundry.identity.uaa.user.UaaAuthority; import org.cloudfoundry.identity.uaa.user.UaaUser; import org.cloudfoundry.identity.uaa.user.UaaUserDatabase; @@ -272,6 +273,89 @@ void group_white_list_with_wildcard() { assertThat(am.getExternalUserAuthorities(authDetails, authenticationData)).containsExactlyInAnyOrder("ldap.role.1.a", "ldap.role.1.b", "ldap.role.1", "ldap.role.2.a", "ldap.role.2.b", "ldap.role.2"); } + @Test + void group_white_list_does_not_match_dn_by_default() { + UaaUser user = getUaaUser(); + ExtendedLdapUserImpl authDetails = + getAuthDetails( + user.getEmail(), + user.getGivenName(), + user.getFamilyName(), + user.getPhoneNumber(), + new AttributeInfo(UAA_MANAGER, new String[]{KARI_THE_ANT_EATER, JOHN_THE_SLOTH}), + new AttributeInfo(COST_CENTER, new String[]{DENVER_CO}) + ); + Map role1 = new HashMap<>(); + role1.put("cn", new String[]{"ldap.role.1"}); + role1.put(SpringSecurityLdapTemplate.DN_KEY, new String[]{"cn=role1,ou=test,ou=com"}); + authDetails.setAuthorities( + Collections.singletonList(new LdapAuthority("role1", "cn=role1,ou=test,ou=com", role1)) + ); + + final ExternalAuthenticationDetails authenticationData = ExternalAuthenticationDetails.builder().origin(origin).build(); + + definition.setExternalGroupsWhitelist(Collections.singletonList("cn=role1,ou=test,ou=com")); + assertThat(am.getExternalUserAuthorities(authDetails, authenticationData)).containsExactlyInAnyOrder(); + } + + @Test + void group_white_list_matches_dn_when_include_dn_enabled() { + UaaUser user = getUaaUser(); + ExtendedLdapUserImpl authDetails = + getAuthDetails( + user.getEmail(), + user.getGivenName(), + user.getFamilyName(), + user.getPhoneNumber(), + new AttributeInfo(UAA_MANAGER, new String[]{KARI_THE_ANT_EATER, JOHN_THE_SLOTH}), + new AttributeInfo(COST_CENTER, new String[]{DENVER_CO}) + ); + Map role1 = new HashMap<>(); + role1.put("cn", new String[]{"ldap.role.1"}); + role1.put(SpringSecurityLdapTemplate.DN_KEY, new String[]{"cn=role1,ou=test,ou=com"}); + authDetails.setAuthorities( + Collections.singletonList(new LdapAuthority("role1", "cn=role1,ou=test,ou=com", role1)) + ); + + final ExternalAuthenticationDetails authenticationData = ExternalAuthenticationDetails.builder().origin(origin).build(); + + definition.setIncludeExternalGroupDn(true); + definition.setExternalGroupsWhitelist(Collections.singletonList("cn=role1,ou=test,ou=com")); + assertThat(am.getExternalUserAuthorities(authDetails, authenticationData)).containsExactlyInAnyOrder("cn=role1,ou=test,ou=com"); + } + + @Test + void group_white_list_matches_both_cn_and_dn_when_include_dn_enabled() { + UaaUser user = getUaaUser(); + ExtendedLdapUserImpl authDetails = + getAuthDetails( + user.getEmail(), + user.getGivenName(), + user.getFamilyName(), + user.getPhoneNumber(), + new AttributeInfo(UAA_MANAGER, new String[]{KARI_THE_ANT_EATER, JOHN_THE_SLOTH}), + new AttributeInfo(COST_CENTER, new String[]{DENVER_CO}) + ); + Map role1 = new HashMap<>(); + role1.put("cn", new String[]{"ldap.role.1"}); + role1.put(SpringSecurityLdapTemplate.DN_KEY, new String[]{"cn=role1,ou=test,ou=com"}); + Map role2 = new HashMap<>(); + role2.put("cn", new String[]{"ldap.role.2"}); + role2.put(SpringSecurityLdapTemplate.DN_KEY, new String[]{"cn=role2,ou=test,ou=com"}); + authDetails.setAuthorities( + Arrays.asList( + new LdapAuthority("role1", "cn=role1,ou=test,ou=com", role1), + new LdapAuthority("role2", "cn=role2,ou=test,ou=com", role2) + ) + ); + + final ExternalAuthenticationDetails authenticationData = ExternalAuthenticationDetails.builder().origin(origin).build(); + + definition.setIncludeExternalGroupDn(true); + definition.setExternalGroupsWhitelist(Arrays.asList("cn=role1,ou=test,ou=com", "ldap.role.2")); + assertThat(am.getExternalUserAuthorities(authDetails, authenticationData)).containsExactlyInAnyOrder("cn=role1,ou=test,ou=com", "ldap.role.2"); + } + void test_authentication_attributes(boolean storeUserInfo) { UaaUser user = getUaaUser(); diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/provider/ldap/LdapIdentityProviderDefinitionTest.java b/server/src/test/java/org/cloudfoundry/identity/uaa/provider/ldap/LdapIdentityProviderDefinitionTest.java index 9bad68d29d9..7fc2f1d7731 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/provider/ldap/LdapIdentityProviderDefinitionTest.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/provider/ldap/LdapIdentityProviderDefinitionTest.java @@ -35,6 +35,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.assertj.core.api.Assertions.assertThat; import static org.cloudfoundry.identity.uaa.constants.OriginKeys.LDAP; +import static org.cloudfoundry.identity.uaa.provider.LdapIdentityProviderDefinition.LDAP_INCLUDE_EXTERNAL_GROUP_DN; import static org.cloudfoundry.identity.uaa.provider.LdapIdentityProviderDefinition.LDAP_PROPERTY_TYPES; import static org.cloudfoundry.identity.uaa.provider.LdapIdentityProviderDefinition.LDAP_SSL_TLS; import static org.cloudfoundry.identity.uaa.provider.LdapIdentityProviderDefinition.LDAP_TLS_EXTERNAL; @@ -48,6 +49,7 @@ class LdapIdentityProviderDefinitionTest { @Test void property_types() { assertThat(LDAP_PROPERTY_TYPES).containsEntry(LDAP_SSL_TLS, String.class); + assertThat(LDAP_PROPERTY_TYPES).containsEntry(LDAP_INCLUDE_EXTERNAL_GROUP_DN, Boolean.class); } @Test @@ -65,6 +67,12 @@ void equals() { ldapIdentityProviderDefinition2.setAddShadowUserOnLogin(true); assertThat(ldapIdentityProviderDefinition2).isEqualTo(ldapIdentityProviderDefinition1); + + ldapIdentityProviderDefinition1.setIncludeExternalGroupDn(true); + assertThat(ldapIdentityProviderDefinition2).isNotEqualTo(ldapIdentityProviderDefinition1); + + ldapIdentityProviderDefinition2.setIncludeExternalGroupDn(true); + assertThat(ldapIdentityProviderDefinition2).isEqualTo(ldapIdentityProviderDefinition1); } @Test @@ -238,6 +246,7 @@ void simple_bind_config() { assertThat(def.getMaxGroupSearchDepth()).isEqualTo(10); assertThat(def.isAutoAddGroups()).isTrue(); assertThat(def.getGroupRoleAttribute()).isNull(); + assertThat(def.isIncludeExternalGroupDn()).isFalse(); } @Test @@ -275,6 +284,7 @@ void search_and_bind_config() { assertThat(def.getMaxGroupSearchDepth()).isEqualTo(10); assertThat(def.isAutoAddGroups()).isTrue(); assertThat(def.getGroupRoleAttribute()).isNull(); + assertThat(def.isIncludeExternalGroupDn()).isFalse(); } @Test @@ -321,6 +331,25 @@ void search_and_bind_with_groups_config() { assertThat(def.getGroupRoleAttribute()).isNull(); } + @Test + void search_and_bind_with_include_external_group_dn_config() { + String config = """ + ldap: + profile: + file: ldap/ldap-search-and-bind.xml + base: + url: 'ldap://localhost:10389/' + mailAttributeName: mail + userDn: 'cn=admin,ou=Users,dc=test,dc=com' + password: 'password' + searchBase: '' + searchFilter: 'cn={0}' + includeExternalGroupDn: true"""; + LdapIdentityProviderDefinition def = LdapUtils.fromConfig(getLdapConfig(config)); + + assertThat(def.isIncludeExternalGroupDn()).isTrue(); + } + @Test void search_and_compare_config() { String config = """ @@ -447,6 +476,16 @@ void set_external_groups_whitelist() { assertThat(def.getExternalGroupsWhitelist()).containsExactlyElementsOf(Collections.singletonList("value")); } + @Test + void set_include_external_group_dn() { + LdapIdentityProviderDefinition def = new LdapIdentityProviderDefinition(); + assertThat(def.isIncludeExternalGroupDn()).isFalse(); + def.setIncludeExternalGroupDn(true); + assertThat(def.isIncludeExternalGroupDn()).isTrue(); + def = JsonUtils.readValue(JsonUtils.writeValueAsString(def), LdapIdentityProviderDefinition.class); + assertThat(def.isIncludeExternalGroupDn()).isTrue(); + } + @Test void set_user_attributes() { LdapIdentityProviderDefinition def = new LdapIdentityProviderDefinition(); diff --git a/uaa/src/test/java/org/cloudfoundry/identity/uaa/mock/ldap/AbstractLdapMockMvcTest.java b/uaa/src/test/java/org/cloudfoundry/identity/uaa/mock/ldap/AbstractLdapMockMvcTest.java index 3f06fa65c07..4fa68238adc 100644 --- a/uaa/src/test/java/org/cloudfoundry/identity/uaa/mock/ldap/AbstractLdapMockMvcTest.java +++ b/uaa/src/test/java/org/cloudfoundry/identity/uaa/mock/ldap/AbstractLdapMockMvcTest.java @@ -403,6 +403,40 @@ void external_groups_whitelist() throws Exception { IdentityZoneHolder.clear(); } + @Test + void external_groups_whitelist_by_dn() throws Exception { + assumeTrue("ldap-groups-map-to-scopes.xml, ldap-groups-as-scopes.xml".contains(ldapGroup)); + AuthenticationManager manager = getWebApplicationContext().getBean(DynamicZoneAwareAuthenticationManager.class); + UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken("marissa3", "ldap3"); + + LdapIdentityProviderDefinition def = provider.getConfig(); + def.addWhiteListedGroup("cn=admins,ou=scopes,dc=test,dc=com"); + provider.setConfig(def); + updateLdapProvider(); + + IdentityZoneHolder.set(zone.getZone().getIdentityZone()); + Authentication auth = manager.authenticate(token); + assertThat(auth).isInstanceOf(UaaAuthentication.class); + UaaAuthentication uaaAuth = (UaaAuthentication) auth; + Set externalGroups = uaaAuth.getExternalGroups(); + //by default, only the bare cn is reported, so a DN-based whitelist entry never matches + assertThat(externalGroups).isEmpty(); + + //enabling includeExternalGroupDn adds the DN as an additional matchable value + def = provider.getConfig(); + def.setIncludeExternalGroupDn(true); + provider.setConfig(def); + updateLdapProvider(); + IdentityZoneHolder.set(zone.getZone().getIdentityZone()); + auth = manager.authenticate(token); + assertThat(auth).isInstanceOf(UaaAuthentication.class); + uaaAuth = (UaaAuthentication) auth; + externalGroups = uaaAuth.getExternalGroups(); + assertThat(externalGroups).containsExactlyInAnyOrder("cn=admins,ou=scopes,dc=test,dc=com"); + + IdentityZoneHolder.clear(); + } + @Test void customUserAttributes() throws Exception { assumeTrue("ldap-groups-map-to-scopes.xml, ldap-groups-as-scopes.xml".contains(ldapGroup)); From b5949dffca39d9bd5910f81bb6de47aeef6d7f4a Mon Sep 17 00:00:00 2001 From: Duane May Date: Wed, 15 Jul 2026 15:24:36 -0400 Subject: [PATCH 2/5] Support DN as an external group name for LDAP when includeExternalGroupDn is enabled UAA only ever surfaced the bare cn of an LDAP group as its "external group" name, discarding the full DN even though it's already present on every LdapAuthority. This broke CF's external-group role assignment feature when the CF-side mapping was configured using a group's full DN, since that string could never match. getAuthoritiesAsNames() now unions the DN in as an additional candidate name (never replacing cn) when includeExternalGroupDn is enabled, so existing cn-based whitelist/scope configs are unaffected by default and admins can opt in to DN-based matching. Makes the previously-failing tests from the prior commit pass. --- .../manager/LdapLoginAuthenticationManager.java | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/authentication/manager/LdapLoginAuthenticationManager.java b/server/src/main/java/org/cloudfoundry/identity/uaa/authentication/manager/LdapLoginAuthenticationManager.java index c485f418504..0b9025c759c 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/authentication/manager/LdapLoginAuthenticationManager.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/authentication/manager/LdapLoginAuthenticationManager.java @@ -24,6 +24,7 @@ import org.cloudfoundry.identity.uaa.provider.LdapIdentityProviderDefinition; import org.cloudfoundry.identity.uaa.provider.ldap.ExtendedLdapUserDetails; import org.cloudfoundry.identity.uaa.provider.ldap.extension.LdapAuthority; +import org.cloudfoundry.identity.uaa.provider.ldap.extension.SpringSecurityLdapTemplate; import org.cloudfoundry.identity.uaa.user.UaaUser; import org.cloudfoundry.identity.uaa.util.ObjectUtils; import org.cloudfoundry.identity.uaa.zone.IdentityZoneHolder; @@ -97,12 +98,13 @@ protected List getExternalUserAuthorities(UserDetails request, ExternalA IdentityProvider provider = getProviderProvisioning().retrieveByOrigin(authenticationData.getOrigin(), IdentityZoneHolder.get().getId()); LdapIdentityProviderDefinition ldapIdentityProviderDefinition = ObjectUtils.castInstance(provider.getConfig(), LdapIdentityProviderDefinition.class); List externalWhiteList = ldapIdentityProviderDefinition.getExternalGroupsWhitelist(); - result = new ArrayList<>(retainAllMatches(getAuthoritiesAsNames(request.getAuthorities()), externalWhiteList)); + boolean includeDn = Boolean.TRUE.equals(ldapIdentityProviderDefinition.isIncludeExternalGroupDn()); + result = new ArrayList<>(retainAllMatches(getAuthoritiesAsNames(request.getAuthorities(), includeDn), externalWhiteList)); } return result; } - protected Set getAuthoritiesAsNames(Collection authorities) { + protected Set getAuthoritiesAsNames(Collection authorities, boolean includeDn) { Set result = new HashSet<>(); authorities = new LinkedList<>(authorities != null ? authorities : emptyList()); for (GrantedAuthority a : authorities) { @@ -111,6 +113,12 @@ protected Set getAuthoritiesAsNames(Collection Date: Wed, 15 Jul 2026 16:08:35 -0400 Subject: [PATCH 3/5] Document includeExternalGroupDn LDAP config property Adds the new property to UAA-LDAP.md and UAA-APIs.rst (also correcting the existing externalGroupsWhitelist description, which incorrectly claimed DN values were already supported), a commented uaa.yml sample, and the REST Docs field descriptor for the identity providers API snippet generator. --- docs/UAA-APIs.rst | 3 ++- docs/UAA-LDAP.md | 15 +++++++++++++-- uaa/src/main/resources/uaa.yml | 2 ++ .../providers/IdentityProviderEndpointDocs.java | 7 ++++++- 4 files changed, 23 insertions(+), 4 deletions(-) diff --git a/docs/UAA-APIs.rst b/docs/UAA-APIs.rst index 74bf6d2ef7a..4bc436b353d 100644 --- a/docs/UAA-APIs.rst +++ b/docs/UAA-APIs.rst @@ -1390,7 +1390,8 @@ Fields *Available Fields* :: skipSSLVerification boolean Optional Set to true if you wish to skip SSL certificate verification emailDomain List Optional List of email domains associated with the LDAP provider for the purpose of associating users to the correct origin upon invitation. If null or empty list, no invitations are accepted. Wildcards supported. attributeMappings Map Optional List of UAA attributes mapped to attributes from LDAP. Currently we support mapping given_name, family_name, email, phone_number and external_groups. - externalGroupsWhitelist List Optional List of external groups (`DN` distinguished names`) that can be included in the ID Token if the `roles` scope is requested. See `UAA-LDAP.md UAA-LDAP.md`_ for more information + externalGroupsWhitelist List Optional List of external groups that can be included in the ID Token if the `roles` scope is requested. By default matched against the bare `cn` of each LDAP group; set `includeExternalGroupDn` to `true` to also allow matching on the group's full `DN`. See `UAA-LDAP.md UAA-LDAP.md`_ for more information + includeExternalGroupDn boolean Optional Defaults to false. When true, the full `DN` of each LDAP group is added as an additional candidate value (alongside the existing `cn` value) for `externalGroupsWhitelist` matching and the `/userinfo` `roles` field. See `UAA-LDAP.md UAA-LDAP.md`_ for more information providerDescription String Optional Human readable name/description of this provider Curl Example POST (Creating a SAML provider):: diff --git a/docs/UAA-LDAP.md b/docs/UAA-LDAP.md index d828c70f55f..da14ec3a2f1 100644 --- a/docs/UAA-LDAP.md +++ b/docs/UAA-LDAP.md @@ -684,8 +684,19 @@ In the above example, the user `marissa`'s UAA email always become `generated-m * `ldap.externalGroupsWhitelist` List value, Optional List of external groups that will be included in the ID Token if the `roles` scope is requested. - The list should contain `DN` values for the groups that are associated with the user. - The display name of the group in the ID token will be the taken from the `ldap.groups.groupRoleAttribute` attribute + By default, the list is matched against the bare `cn` of each LDAP group the user belongs to (e.g. `cf_admin`), + not the group's full `DN`. Set `ldap.includeExternalGroupDn` to `true` to also allow matching on the full `DN` + (e.g. `cn=cf_admin,ou=dev,dc=example,dc=com`). + + +* `ldap.includeExternalGroupDn` + boolean value, defaults to `false`. + When `true`, the full `DN` of each LDAP group the user belongs to is added as an additional candidate value + (alongside the existing bare `cn` value) when matching against `ldap.externalGroupsWhitelist` and when + populating the external groups reported via the `/userinfo` endpoint's `roles` field. This does not replace + the `cn`-based value, so existing `cn`-based whitelist entries keep working when this is enabled. + Useful when an external system (for example, a role-to-external-group mapping configured with a group's + full `DN`) needs to match against the `DN` rather than the bare `cn`. * `ldap.attributeMappings` diff --git a/uaa/src/main/resources/uaa.yml b/uaa/src/main/resources/uaa.yml index 21969b0763c..c039730d147 100755 --- a/uaa/src/main/resources/uaa.yml +++ b/uaa/src/main/resources/uaa.yml @@ -99,6 +99,7 @@ zones: # externalGroupsWhitelist: # - admin # - user +# includeExternalGroupDn: false # emailDomain: # - example.com # attributeMappings: @@ -612,6 +613,7 @@ ldap: autoAdd: true externalGroupsWhitelist: - '*' + includeExternalGroupDn: false springdoc: api-docs: diff --git a/uaa/src/test/java/org/cloudfoundry/identity/uaa/mock/providers/IdentityProviderEndpointDocs.java b/uaa/src/test/java/org/cloudfoundry/identity/uaa/mock/providers/IdentityProviderEndpointDocs.java index 74e3b9daa15..75f1d491bbf 100644 --- a/uaa/src/test/java/org/cloudfoundry/identity/uaa/mock/providers/IdentityProviderEndpointDocs.java +++ b/uaa/src/test/java/org/cloudfoundry/identity/uaa/mock/providers/IdentityProviderEndpointDocs.java @@ -274,6 +274,7 @@ static void startLdapContainer() { private final FieldDescriptor ldapUserCompareEncoder = fieldWithPath("config.passwordEncoder").optional("org.cloudfoundry.identity.uaa.provider.ldap.DynamicPasswordComparator").type(STRING).description("Used with `search-and-compare` only. A fully-qualified Java classname to the password encoder. This encoder is used to properly encode user password to match the one in the LDAP directory."); private final FieldDescriptor ldapUserCompareLocal = fieldWithPath("config.localPasswordCompare").optional(null).type(BOOLEAN).description("Set to true if the comparison should be done locally. Setting this value to false implies that rather than retrieving the password, the UAA will run a query to match the password. In order for this query to work, you must know what type of hash/encoding/salt is used for the LDAP password."); private final FieldDescriptor ldapGroupRoleAttribute = fieldWithPath("config.groupRoleAttribute").optional("description").type(STRING).description("Used with `groups-as-scopes`, defines the attribute that holds the scope name(s)."); + private final FieldDescriptor ldapIncludeExternalGroupDn = fieldWithPath("config.includeExternalGroupDn").optional(false).type(BOOLEAN).description("Defaults to false. When true, the full `DN` of each LDAP group is added as an additional candidate value (alongside the existing `cn` value) for `externalGroupsWhitelist` matching and the `/userinfo` `roles` field."); private final FieldDescriptor ldapAttributeMappingFirstname = fieldWithPath("config.attributeMappings.first_name").optional("givenname").type(STRING).description(GIVEN_NAME_DESC); private final FieldDescriptor ldapAttributeMappingLastname = fieldWithPath("config.attributeMappings.family_name").optional("sn").type(STRING).description(FAMILY_NAME_DESC); private final FieldDescriptor ldapAttributeMappingPhone = fieldWithPath("config.attributeMappings.phone_number").optional("telephonenumber").type(STRING).description(PHONE_NUMBER_DESC); @@ -309,6 +310,7 @@ static void startLdapContainer() { ldapUserCompareEncoder, ldapUserCompareLocal, ldapGroupRoleAttribute, + ldapIncludeExternalGroupDn, ATTRIBUTE_MAPPING, ldapAttributeMappingUserName, ldapAttributeMappingFirstname, @@ -348,6 +350,7 @@ static void startLdapContainer() { ldapUserCompareEncoder, ldapUserCompareLocal, ldapGroupRoleAttribute, + ldapIncludeExternalGroupDn, ATTRIBUTE_MAPPING, ldapAttributeMappingUserName, ldapAttributeMappingFirstname, @@ -396,7 +399,8 @@ static void startLdapContainer() { ldapUserCompareEncoder.ignored(), ldapUserCompareLocal.ignored(), ldapGroupRoleAttribute.ignored(), - EXTERNAL_GROUPS_WHITELIST.ignored() + EXTERNAL_GROUPS_WHITELIST.ignored(), + ldapIncludeExternalGroupDn.ignored() }, ALIAS_FIELDS_LDAP_CREATE ) @@ -433,6 +437,7 @@ static void startLdapContainer() { ldapUserCompareEncoder.ignored(), ldapUserCompareLocal.ignored(), ldapGroupRoleAttribute.ignored(), + ldapIncludeExternalGroupDn, ATTRIBUTE_MAPPING, ldapAttributeMappingUserName, ldapAttributeMappingFirstname, From c089dce3dc886b5dd1bb38c676f59070f06b9ca2 Mon Sep 17 00:00:00 2001 From: Duane May Date: Wed, 15 Jul 2026 17:11:47 -0400 Subject: [PATCH 4/5] Backfill LDAP config reference for groupRoleAttribute, externalGroupsWhitelist, includeExternalGroupDn UAA-Configuration-Reference.md's LDAP table was missing entries for groupRoleAttribute and externalGroupsWhitelist even though both are documented elsewhere (UAA-LDAP.md, UAA-APIs.rst). Backfilling those alongside the new includeExternalGroupDn property so the table and its per-property detail sections stay internally consistent. --- docs/UAA-Configuration-Reference.md | 48 +++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/docs/UAA-Configuration-Reference.md b/docs/UAA-Configuration-Reference.md index 7fc4a0a401f..c3950c89a87 100644 --- a/docs/UAA-Configuration-Reference.md +++ b/docs/UAA-Configuration-Reference.md @@ -294,6 +294,9 @@ or `$CLOUDFOUNDRY_CONFIG_PATH/uaa.yml`. | `ldap.groups.groupSearchFilter` | —| Group membership filter| | `ldap.groups.maxSearchDepth` | `10`| Max nested group depth| | `ldap.groups.autoAdd` | —| Auto-add LDAP groups| +| `ldap.groups.groupRoleAttribute` | `description`| Attribute holding the scope name(s) for `groups-as-scopes`| +| `ldap.externalGroupsWhitelist` | —| External group names/patterns allowed into the `roles` field| +| `ldap.includeExternalGroupDn` | `false`| Also match external groups by full DN| ### Encryption @@ -2686,6 +2689,51 @@ When `true`, LDAP groups are automatically created as UAA groups if they don't e --- +### `ldap.groups.groupRoleAttribute` + +**Default:** `description` +**Source:** YAML config +**Type:** `String` + +Used with `groups-as-scopes` (`ldap.groups.file: ldap/ldap-groups-as-scopes.xml`), defines the +LDAP attribute that holds the scope name(s) for a group. Ignored by `groups-map-to-scopes`, where +the group's DN is used instead. + +[Back to table](#ldap) + +--- + +### `ldap.externalGroupsWhitelist` + +**Default:** — (not set, meaning all external groups are allowed) +**Source:** YAML config +**Type:** `List` + +Allowlist of external group names/patterns (wildcards supported) that may be reported as the +user's external groups — used to populate the `/userinfo` endpoint's `roles` field. By default, +each LDAP group the user belongs to is matched by its bare `cn` (e.g. `cf_admin`), not its full +`DN`. Set `ldap.includeExternalGroupDn` to `true` to also allow matching on the group's full `DN`. + +[Back to table](#ldap) + +--- + +### `ldap.includeExternalGroupDn` + +**Default:** `false` +**Source:** YAML config +**Type:** `boolean` + +When `true`, the full `DN` of each LDAP group the user belongs to is added as an additional +candidate value (alongside the existing `cn` value) when matching against +`ldap.externalGroupsWhitelist` and when populating the `/userinfo` endpoint's `roles` field. This +does not replace the `cn`-based value, so existing `cn`-based whitelist entries keep working when +this is enabled. + +[Back to table](#ldap) + +--- + ### `encryption.active_key_label` **Default:** — (required) From 39e8366f6529006a68cfc6678757aa7bbab72a8f Mon Sep 17 00:00:00 2001 From: Duane May Date: Mon, 20 Jul 2026 14:37:55 -0400 Subject: [PATCH 5/5] Document token/payload size caveat for includeExternalGroupDn Enabling the flag doubles entry count per group, but DN strings are typically far longer than the bare cn (often ~6x), so payload growth is driven more by DN length than the doubled count. Worth flagging before enabling broadly with a wildcard externalGroupsWhitelist. --- docs/UAA-Configuration-Reference.md | 9 +++++++++ docs/UAA-LDAP.md | 7 +++++++ 2 files changed, 16 insertions(+) diff --git a/docs/UAA-Configuration-Reference.md b/docs/UAA-Configuration-Reference.md index c3950c89a87..967911c1e1b 100644 --- a/docs/UAA-Configuration-Reference.md +++ b/docs/UAA-Configuration-Reference.md @@ -2730,6 +2730,15 @@ candidate value (alongside the existing `cn` value) when matching against does not replace the `cn`-based value, so existing `cn`-based whitelist entries keep working when this is enabled. +**Caution:** this can meaningfully grow the size of the `/userinfo` response and any token that +carries the `roles` claim. Each group now contributes two entries instead of one, and a `DN` is +typically far longer than a bare `cn` (e.g. `cf_admin` vs. +`cn=cf_admin,ou=dev,dc=example,dc=com` — roughly 6x the characters), so the size increase is +driven more by `DN` length than by the doubled entry count. For a user in many or deeply-nested +groups, with a broad `externalGroupsWhitelist` (e.g. `*`), this can add up to a substantial +increase in payload size — worth checking against any downstream token/header size limits before +enabling broadly. + [Back to table](#ldap) --- diff --git a/docs/UAA-LDAP.md b/docs/UAA-LDAP.md index da14ec3a2f1..2523f1fe6fa 100644 --- a/docs/UAA-LDAP.md +++ b/docs/UAA-LDAP.md @@ -697,6 +697,13 @@ In the above example, the user `marissa`'s UAA email always become `generated-m the `cn`-based value, so existing `cn`-based whitelist entries keep working when this is enabled. Useful when an external system (for example, a role-to-external-group mapping configured with a group's full `DN`) needs to match against the `DN` rather than the bare `cn`. +
**Caution:** this can meaningfully grow the size of the `/userinfo` response and any token that carries + the `roles` claim. Each group now contributes two entries instead of one, and a `DN` is typically far longer + than a bare `cn` (e.g. `cf_admin` vs. `cn=cf_admin,ou=dev,dc=example,dc=com` — roughly 6x the characters), so + the size increase is driven more by `DN` length than by the doubled entry count. For a user in many or + deeply-nested groups, with a broad `externalGroupsWhitelist` (e.g. `*`), this can add up to a substantial + increase in payload size — worth checking against any downstream token/header size limits before enabling + broadly. * `ldap.attributeMappings`