Skip to content

Commit 27956f6

Browse files
feat(ldap): add recursive AD group membership support via LDAP_MATCHING_RULE_IN_CHAIN
Fixes #26889
1 parent f3ae6cf commit 27956f6

4 files changed

Lines changed: 302 additions & 2 deletions

File tree

openmetadata-service/src/main/java/org/openmetadata/service/security/auth/LdapAuthenticator.java

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@
8282
@Slf4j
8383
public class LdapAuthenticator implements AuthenticatorHandler {
8484
static final String LDAP_ERR_MSG = "[LDAP] Issue in creating a LookUp Connection ";
85+
static final String AD_RECURSIVE_GROUP_MATCHING_RULE = "1.2.840.113556.1.4.1941";
8586
private static final int MAX_RETRIES = 3;
8687
private static final int BASE_DELAY_MS = 500;
8788
private RoleRepository roleRepository;
@@ -386,6 +387,34 @@ userName, new CreateUser().withName(userName).withEmail(email).withIsBot(false))
386387
return user;
387388
}
388389

390+
391+
/**
392+
* Builds the LDAP filter used to match group membership for a given user DN.
393+
*
394+
* <p>When {@code recursiveGroupMembership} is enabled in the LDAP configuration,
395+
* this method uses Active Directory's {@code LDAP_MATCHING_RULE_IN_CHAIN} extensible
396+
* match rule (OID {@value AD_RECURSIVE_GROUP_MATCHING_RULE}) to resolve transitive
397+
* (nested) group membership server-side without requiring multiple queries.
398+
*
399+
* <p>When the flag is disabled (default), a standard equality filter is used
400+
* which only matches direct group members.
401+
*
402+
* @param ldapConfiguration the current LDAP configuration
403+
* @param userDn the distinguished name of the user being checked
404+
* @return a {@link Filter} for use in the group membership LDAP search
405+
*/
406+
Filter buildGroupMemberFilter(LdapConfiguration ldapConfiguration, String userDn) {
407+
if (Boolean.TRUE.equals(ldapConfiguration.getRecursiveGroupMembership())) {
408+
return Filter.createExtensibleMatchFilter(
409+
ldapConfiguration.getGroupMemberAttributeName(),
410+
AD_RECURSIVE_GROUP_MATCHING_RULE,
411+
false,
412+
userDn);
413+
}
414+
return Filter.createEqualityFilter(
415+
ldapConfiguration.getGroupMemberAttributeName(), userDn);
416+
}
417+
389418
/**
390419
* Getting user's roles according to the mapping between ldap groups and roles
391420
*/
@@ -397,8 +426,7 @@ private void getRoleForLdap(String userDn, User user, Boolean reAssign)
397426
Filter.createEqualityFilter(
398427
ldapConfiguration.getGroupAttributeName(),
399428
ldapConfiguration.getGroupAttributeValue());
400-
Filter groupMemberAttr =
401-
Filter.createEqualityFilter(ldapConfiguration.getGroupMemberAttributeName(), userDn);
429+
Filter groupMemberAttr = buildGroupMemberFilter(ldapConfiguration, userDn);
402430
Filter groupAndMemberFilter = Filter.createANDFilter(groupFilter, groupMemberAttr);
403431
SearchRequest searchRequest =
404432
new SearchRequest(
Lines changed: 259 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,259 @@
1+
/*
2+
* Copyright 2021 Collate.
3+
* Licensed under the Apache License, Version 2.0 (the "License");
4+
* you may not use this file except in compliance with the License.
5+
* You may obtain a copy of the License at
6+
* http://www.apache.org/licenses/LICENSE-2.0
7+
* Unless required by applicable law or agreed to in writing, software
8+
* distributed under the License is distributed on an "AS IS" BASIS,
9+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
10+
* See the License for the specific language governing permissions and
11+
* limitations under the License.
12+
*/
13+
14+
package org.openmetadata.service.security.auth;
15+
16+
import static org.junit.jupiter.api.Assertions.assertEquals;
17+
import static org.junit.jupiter.api.Assertions.assertNotNull;
18+
import static org.junit.jupiter.api.Assertions.assertNull;
19+
import static org.mockito.Mockito.mock;
20+
import static org.mockito.Mockito.when;
21+
22+
import com.unboundid.ldap.sdk.Filter;
23+
import com.unboundid.ldap.sdk.LDAPException;
24+
import org.junit.jupiter.api.BeforeEach;
25+
import org.junit.jupiter.api.DisplayName;
26+
import org.junit.jupiter.api.Nested;
27+
import org.junit.jupiter.api.Test;
28+
import org.junit.jupiter.api.extension.ExtendWith;
29+
import org.mockito.junit.jupiter.MockitoExtension;
30+
import org.openmetadata.schema.auth.LdapConfiguration;
31+
32+
/**
33+
* Unit tests for {@link LdapAuthenticator}.
34+
*
35+
* <p>Focuses on the group membership filter construction logic introduced in
36+
* issue #26889 (recursive/nested Active Directory group membership support).
37+
*
38+
* <p>Tests validate both the standard equality filter (direct membership)
39+
* and the extensible match filter (recursive membership via
40+
* LDAP_MATCHING_RULE_IN_CHAIN OID 1.2.840.113556.1.4.1941).
41+
*/
42+
@ExtendWith(MockitoExtension.class)
43+
class LdapAuthenticatorTest {
44+
45+
private static final String TEST_USER_DN =
46+
"CN=John Doe,OU=Users,DC=example,DC=com";
47+
private static final String TEST_GROUP_MEMBER_ATTR = "member";
48+
private static final String EXPECTED_OID = "1.2.840.113556.1.4.1941";
49+
50+
private LdapAuthenticator ldapAuthenticator;
51+
private LdapConfiguration ldapConfiguration;
52+
53+
@BeforeEach
54+
void setUp() {
55+
ldapAuthenticator = new LdapAuthenticator();
56+
ldapConfiguration = mock(LdapConfiguration.class);
57+
when(ldapConfiguration.getGroupMemberAttributeName())
58+
.thenReturn(TEST_GROUP_MEMBER_ATTR);
59+
}
60+
61+
@Nested
62+
@DisplayName("buildGroupMemberFilter() — recursive membership DISABLED")
63+
class WhenRecursiveMembershipIsDisabled {
64+
65+
@BeforeEach
66+
void disableRecursiveMembership() {
67+
when(ldapConfiguration.getRecursiveGroupMembership()).thenReturn(Boolean.FALSE);
68+
}
69+
70+
@Test
71+
@DisplayName("returns an equality filter when recursiveGroupMembership is false")
72+
void buildGroupMemberFilter_returnsEqualityFilter_whenRecursiveIsDisabled()
73+
throws LDAPException {
74+
Filter filter =
75+
ldapAuthenticator.buildGroupMemberFilter(ldapConfiguration, TEST_USER_DN);
76+
77+
assertNotNull(filter, "Filter must not be null");
78+
assertEquals(
79+
"member=" + TEST_USER_DN,
80+
filter.toString(),
81+
"Filter must be a simple equality filter matching member attribute");
82+
assertNull(
83+
filter.getMatchingRuleID(),
84+
"Equality filter must NOT have a matching rule ID (OID)");
85+
}
86+
87+
@Test
88+
@DisplayName("equality filter attribute type matches groupMemberAttributeName")
89+
void buildGroupMemberFilter_equalityFilter_usesCorrectAttribute()
90+
throws LDAPException {
91+
Filter filter =
92+
ldapAuthenticator.buildGroupMemberFilter(ldapConfiguration, TEST_USER_DN);
93+
94+
assertNotNull(filter);
95+
assertEquals(
96+
TEST_GROUP_MEMBER_ATTR,
97+
filter.getAttributeName(),
98+
"Filter attribute must match groupMemberAttributeName from config");
99+
}
100+
101+
@Test
102+
@DisplayName("equality filter assertion value matches userDn")
103+
void buildGroupMemberFilter_equalityFilter_usesCorrectUserDn()
104+
throws LDAPException {
105+
Filter filter =
106+
ldapAuthenticator.buildGroupMemberFilter(ldapConfiguration, TEST_USER_DN);
107+
108+
assertNotNull(filter);
109+
assertEquals(
110+
TEST_USER_DN,
111+
filter.getAssertionValue(),
112+
"Filter assertion value must match the provided userDn");
113+
}
114+
115+
@Test
116+
@DisplayName("returns equality filter when recursiveGroupMembership is null")
117+
void buildGroupMemberFilter_returnsEqualityFilter_whenRecursiveIsNull()
118+
throws LDAPException {
119+
when(ldapConfiguration.getRecursiveGroupMembership()).thenReturn(null);
120+
121+
Filter filter =
122+
ldapAuthenticator.buildGroupMemberFilter(ldapConfiguration, TEST_USER_DN);
123+
124+
assertNotNull(filter, "Filter must not be null even when flag is null");
125+
assertNull(
126+
filter.getMatchingRuleID(),
127+
"Null flag must behave same as false — no OID in filter");
128+
}
129+
}
130+
131+
132+
@Nested
133+
@DisplayName("buildGroupMemberFilter() — recursive membership ENABLED")
134+
class WhenRecursiveMembershipIsEnabled {
135+
136+
@BeforeEach
137+
void enableRecursiveMembership() {
138+
when(ldapConfiguration.getRecursiveGroupMembership()).thenReturn(Boolean.TRUE);
139+
}
140+
141+
@Test
142+
@DisplayName("returns an extensible match filter when recursiveGroupMembership is true")
143+
void buildGroupMemberFilter_returnsExtensibleFilter_whenRecursiveIsEnabled()
144+
throws LDAPException {
145+
Filter filter =
146+
ldapAuthenticator.buildGroupMemberFilter(ldapConfiguration, TEST_USER_DN);
147+
148+
assertNotNull(filter, "Filter must not be null");
149+
assertNotNull(
150+
filter.getMatchingRuleID(),
151+
"Extensible match filter must have a matching rule ID (OID)");
152+
assertEquals(
153+
EXPECTED_OID,
154+
filter.getMatchingRuleID(),
155+
"Matching rule ID must be the AD LDAP_MATCHING_RULE_IN_CHAIN OID");
156+
}
157+
158+
@Test
159+
@DisplayName("extensible filter attribute type matches groupMemberAttributeName")
160+
void buildGroupMemberFilter_extensibleFilter_usesCorrectAttribute()
161+
throws LDAPException {
162+
Filter filter =
163+
ldapAuthenticator.buildGroupMemberFilter(ldapConfiguration, TEST_USER_DN);
164+
165+
assertNotNull(filter);
166+
assertEquals(
167+
TEST_GROUP_MEMBER_ATTR,
168+
filter.getAttributeName(),
169+
"Extensible filter attribute must match groupMemberAttributeName");
170+
}
171+
172+
@Test
173+
@DisplayName("extensible filter assertion value matches userDn")
174+
void buildGroupMemberFilter_extensibleFilter_usesCorrectUserDn()
175+
throws LDAPException {
176+
Filter filter =
177+
ldapAuthenticator.buildGroupMemberFilter(ldapConfiguration, TEST_USER_DN);
178+
179+
assertNotNull(filter);
180+
assertEquals(
181+
TEST_USER_DN,
182+
filter.getAssertionValue(),
183+
"Extensible filter assertion value must match the provided userDn");
184+
}
185+
186+
@Test
187+
@DisplayName("extensible filter dnAttributes flag is false")
188+
void buildGroupMemberFilter_extensibleFilter_dnAttributesIsFalse()
189+
throws LDAPException {
190+
Filter filter =
191+
ldapAuthenticator.buildGroupMemberFilter(ldapConfiguration, TEST_USER_DN);
192+
193+
assertNotNull(filter);
194+
assertEquals(
195+
false,
196+
filter.getDNAttributes(),
197+
"dnAttributes must be false — we match by value not DN components");
198+
}
199+
200+
@Test
201+
@DisplayName("OID constant value is exactly 1.2.840.113556.1.4.1941")
202+
void adRecursiveGroupMatchingRule_constantValue_isCorrect() {
203+
assertEquals(
204+
"1.2.840.113556.1.4.1941",
205+
LdapAuthenticator.AD_RECURSIVE_GROUP_MATCHING_RULE,
206+
"OID constant must exactly match the AD LDAP_MATCHING_RULE_IN_CHAIN value");
207+
}
208+
}
209+
210+
211+
@Nested
212+
@DisplayName("buildGroupMemberFilter() — different userDn values")
213+
class WithDifferentUserDnValues {
214+
215+
@BeforeEach
216+
void disableRecursiveMembership() {
217+
when(ldapConfiguration.getRecursiveGroupMembership()).thenReturn(Boolean.FALSE);
218+
}
219+
220+
@Test
221+
@DisplayName("handles simple userDn correctly")
222+
void buildGroupMemberFilter_handlesSimpleUserDn() throws LDAPException {
223+
String simpleDn = "uid=johndoe,dc=example,dc=com";
224+
Filter filter =
225+
ldapAuthenticator.buildGroupMemberFilter(ldapConfiguration, simpleDn);
226+
227+
assertNotNull(filter);
228+
assertEquals(simpleDn, filter.getAssertionValue());
229+
}
230+
231+
@Test
232+
@DisplayName("handles complex nested userDn correctly")
233+
void buildGroupMemberFilter_handlesComplexUserDn() throws LDAPException {
234+
String complexDn =
235+
"CN=Jane Smith,OU=Engineering,OU=Teams,DC=corp,DC=example,DC=com";
236+
Filter filter =
237+
ldapAuthenticator.buildGroupMemberFilter(ldapConfiguration, complexDn);
238+
239+
assertNotNull(filter);
240+
assertEquals(complexDn, filter.getAssertionValue());
241+
}
242+
243+
@Test
244+
@DisplayName("recursive filter handles complex userDn correctly")
245+
void buildGroupMemberFilter_recursiveFilter_handlesComplexUserDn()
246+
throws LDAPException {
247+
when(ldapConfiguration.getRecursiveGroupMembership()).thenReturn(Boolean.TRUE);
248+
String complexDn =
249+
"CN=Jane Smith,OU=Engineering,OU=Teams,DC=corp,DC=example,DC=com";
250+
251+
Filter filter =
252+
ldapAuthenticator.buildGroupMemberFilter(ldapConfiguration, complexDn);
253+
254+
assertNotNull(filter);
255+
assertEquals(EXPECTED_OID, filter.getMatchingRuleID());
256+
assertEquals(complexDn, filter.getAssertionValue());
257+
}
258+
}
259+
}

openmetadata-spec/src/main/resources/json/schema/configuration/ldapConfiguration.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,11 @@
7373
"description": "Group Member Name attribute name",
7474
"type": "string"
7575
},
76+
"recursiveGroupMembership": {
77+
"description": "Enable opt-in recursive (transitive) group membership resolution for Active Directory nested groups. When true, uses the LDAP_MATCHING_RULE_IN_CHAIN extensible match rule (OID 1.2.840.113556.1.4.1941) to resolve nested group membership server-side. Applicable only when using Active Directory. Issue #26889.",
78+
"type": "boolean",
79+
"default": false
80+
},
7681
"authRolesMapping": {
7782
"description": "Json string of roles mapping between LDAP roles and Ranger roles",
7883
"type": "string"

openmetadata-ui/src/main/resources/ui/src/generated/configuration/ldapConfiguration.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,14 @@ export interface LDAPConfiguration {
5050
* Group Member Name attribute name
5151
*/
5252
groupMemberAttributeName?: string;
53+
/**
54+
* Enable opt-in recursive (transitive) group membership resolution
55+
* for Active Directory nested groups. When true, uses the
56+
* LDAP_MATCHING_RULE_IN_CHAIN extensible match rule
57+
* (OID 1.2.840.113556.1.4.1941) to resolve nested group membership
58+
* server-side. Applicable only when using Active Directory.
59+
*/
60+
recursiveGroupMembership?: boolean;
5361
/**
5462
* LDAP server address without scheme(Example :- localhost)
5563
*/

0 commit comments

Comments
 (0)