Skip to content

Commit e15b974

Browse files
ShourieGmergify[bot]
authored andcommitted
[filebeat][entityanalytics] - Fix AD Entity Analytics failing to fetch users when Base DN contains a group CN (#48395)
entityAnalytics: Fix AD Entity Analytics not returning users when Base DN contains group (CN) Groups are leaf objects in LDAP - users are members of groups, not children. When Base DN includes a group CN (e.g., CN=Admin Users,OU=Groups,DC=...), the search now validates the CN against LDAP and uses a memberOf filter instead of subtree search. Containers like CN=Users continue to work normally. (cherry picked from commit cdc7276)
1 parent b5f24fd commit e15b974

3 files changed

Lines changed: 352 additions & 3 deletions

File tree

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
# REQUIRED
2+
# Kind can be one of:
3+
# - breaking-change: a change to previously-documented behavior
4+
# - deprecation: functionality that is being removed in a later release
5+
# - bug-fix: fixes a problem in a previous version
6+
# - enhancement: extends functionality but does not break or fix existing behavior
7+
# - feature: new functionality
8+
# - known-issue: problems that we are aware of in a given version
9+
# - security: impacts on the security of a product or a user’s deployment.
10+
# - upgrade: important information for someone upgrading from a prior version
11+
# - other: does not fit into any of the other categories
12+
kind: bug-fix
13+
14+
# REQUIRED for all kinds
15+
# Change summary; a 80ish characters long description of the change.
16+
summary: Fix AD Entity Analytics failing to fetch users when Base DN contains a group CN
17+
18+
# REQUIRED for breaking-change, deprecation, known-issue
19+
# Long description; in case the summary is not enough to describe the change
20+
# this field accommodate a description without length limits.
21+
# description:
22+
23+
# REQUIRED for breaking-change, deprecation, known-issue
24+
# impact:
25+
26+
# REQUIRED for breaking-change, deprecation, known-issue
27+
# action:
28+
29+
# REQUIRED for all kinds
30+
# Affected component; usually one of "elastic-agent", "fleet-server", "filebeat", "metricbeat", "auditbeat", "all", etc.
31+
component: filebeat
32+
33+
# AUTOMATED
34+
# OPTIONAL to manually add other PR URLs
35+
# PR URL: A link the PR that added the changeset.
36+
# If not present is automatically filled by the tooling finding the PR where this changelog fragment has been added.
37+
# NOTE: the tooling supports backports, so it's able to fill the original PR number instead of the backport PR number.
38+
# Please provide it if you are adding a fragment for a different PR.
39+
pr: https://github.com/elastic/beats/pull/48395
40+
41+
# AUTOMATED
42+
# OPTIONAL to manually add other issue URLs
43+
# Issue URL; optional; the GitHub issue related to this changeset (either closes or is part of).
44+
# If not present is automatically filled by the tooling with the issue linked to the PR number.
45+
# issue: https://github.com/owner/repo/1234

x-pack/filebeat/input/entityanalytics/provider/activedirectory/internal/activedirectory/activedirectory.go

Lines changed: 172 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,141 @@ var (
2323
ErrUsers = errors.New("failed to get user details")
2424
)
2525

26+
// parsedBaseDN holds the result of parsing a base DN for potential group components.
27+
type parsedBaseDN struct {
28+
// containerBaseDN is the container portion of the DN (OU/DC components).
29+
containerBaseDN string
30+
// potentialGroupDNs are CN components that might be groups (need validation).
31+
potentialGroupDNs []string
32+
// originalBaseDN is the original base DN string.
33+
originalBaseDN string
34+
}
35+
36+
// parseBaseDN analyzes a distinguished name and separates potential group (CN)
37+
// components from container components (OU, DC). CN components that appear
38+
// before container components are extracted as potential group references
39+
// that need to be validated against LDAP.
40+
//
41+
// For example, given:
42+
//
43+
// CN=Admin Users,OU=Groups,DC=example,DC=com
44+
//
45+
// This returns:
46+
// - containerBaseDN: "OU=Groups,DC=example,DC=com" (the container path)
47+
// - potentialGroupDNs: ["CN=Admin Users,OU=Groups,DC=example,DC=com"]
48+
//
49+
// The potential groups must be validated with validateGroupDNs() to confirm
50+
// they are actually groups (objectClass=group) and not containers.
51+
func parseBaseDN(base *ldap.DN) parsedBaseDN {
52+
result := parsedBaseDN{}
53+
if base == nil || len(base.RDNs) == 0 {
54+
return result
55+
}
56+
57+
result.originalBaseDN = base.String()
58+
59+
// Find where container components (OU, DC) start.
60+
// CN components before containers are treated as potential group references.
61+
containerStart := -1
62+
for i, rdn := range base.RDNs {
63+
if len(rdn.Attributes) == 0 {
64+
continue
65+
}
66+
attrType := strings.ToUpper(rdn.Attributes[0].Type)
67+
if attrType == "OU" || attrType == "DC" {
68+
containerStart = i
69+
break
70+
}
71+
}
72+
73+
// If no container components found, or CN components don't precede them,
74+
// use the base DN as-is.
75+
if containerStart <= 0 {
76+
result.containerBaseDN = result.originalBaseDN
77+
return result
78+
}
79+
80+
// Extract potential group DNs (CN components before container start).
81+
for i := 0; i < containerStart; i++ {
82+
rdn := base.RDNs[i]
83+
if len(rdn.Attributes) == 0 {
84+
continue
85+
}
86+
attrType := strings.ToUpper(rdn.Attributes[0].Type)
87+
if attrType == "CN" {
88+
// Build the full DN for this potential group by including it
89+
// and all RDNs after it (the container path).
90+
groupRDNs := base.RDNs[i:]
91+
groupDN := &ldap.DN{RDNs: groupRDNs}
92+
result.potentialGroupDNs = append(result.potentialGroupDNs, groupDN.String())
93+
}
94+
}
95+
96+
// Build the container base DN (starting from first OU or DC).
97+
containerRDNs := base.RDNs[containerStart:]
98+
containerBase := &ldap.DN{RDNs: containerRDNs}
99+
result.containerBaseDN = containerBase.String()
100+
101+
return result
102+
}
103+
104+
// validateGroupDNs queries LDAP to verify which of the potential group DNs
105+
// are actually groups (objectClass=group) vs containers or other object types.
106+
// Returns only the DNs that are confirmed to be groups.
107+
func validateGroupDNs(conn *ldap.Conn, potentialGroupDNs []string) []string {
108+
if len(potentialGroupDNs) == 0 {
109+
return nil
110+
}
111+
112+
var confirmedGroups []string
113+
for _, dn := range potentialGroupDNs {
114+
// Query LDAP to check if this DN is a group.
115+
srch := &ldap.SearchRequest{
116+
BaseDN: dn,
117+
Scope: ldap.ScopeBaseObject, // Only check this specific object
118+
DerefAliases: ldap.NeverDerefAliases,
119+
SizeLimit: 1,
120+
TimeLimit: 0,
121+
TypesOnly: false,
122+
Filter: "(objectClass=group)",
123+
Attributes: []string{"objectClass"},
124+
Controls: nil,
125+
}
126+
127+
result, err := conn.Search(srch)
128+
if err != nil {
129+
// If the search fails (e.g., object doesn't exist), skip this DN.
130+
continue
131+
}
132+
133+
// If we got a result, this DN is a group.
134+
if len(result.Entries) > 0 {
135+
confirmedGroups = append(confirmedGroups, dn)
136+
}
137+
}
138+
139+
return confirmedGroups
140+
}
141+
142+
// buildMemberOfFilter creates an LDAP memberOf filter from a list of group DNs.
143+
// Returns an empty string if no groups are provided.
144+
func buildMemberOfFilter(groupDNs []string) string {
145+
if len(groupDNs) == 0 {
146+
return ""
147+
}
148+
149+
if len(groupDNs) == 1 {
150+
return "(memberOf=" + ldap.EscapeFilter(groupDNs[0]) + ")"
151+
}
152+
153+
// Multiple groups: use OR filter.
154+
var parts []string
155+
for _, dn := range groupDNs {
156+
parts = append(parts, "(memberOf="+ldap.EscapeFilter(dn)+")")
157+
}
158+
return "(|" + strings.Join(parts, "") + ")"
159+
}
160+
26161
// Entry is an Active Directory user entry with associated group membership.
27162
type Entry struct {
28163
ID string `json:"id"`
@@ -45,6 +180,12 @@ type Entry struct {
45180
// users or (&(objectClass=computer)(objectClass=user)) for computers. When
46181
// since is a non-zero time.Time, the query will be conjugated with
47182
// (whenChanged>="<SINCETIME>") into a new query.
183+
//
184+
// If the base DN contains group (CN) components along with container (OU/DC)
185+
// components, the search will automatically extract the group DNs and add
186+
// memberOf filters to find users who are members of those groups. This is
187+
// necessary because groups are leaf objects in LDAP and don't contain users
188+
// as children in the directory tree hierarchy.
48189
func GetDetails(query, url, user, pass string, base *ldap.DN, since time.Time, userAttrs, grpAttrs []string, pagingSize uint32, dialer *net.Dialer, tlsconfig *tls.Config) ([]Entry, error) {
49190
if base == nil || len(base.RDNs) == 0 {
50191
return nil, fmt.Errorf("%w: no path", ErrInvalidDistinguishedName)
@@ -77,7 +218,25 @@ func GetDetails(query, url, user, pass string, base *ldap.DN, since time.Time, u
77218
sinceFmtd = since.Format(denseTimeLayout)
78219
}
79220

80-
baseDN := base.String()
221+
// Parse the base DN to extract any CN components that might be groups.
222+
// Groups are leaf objects in LDAP, so we need to search from the
223+
// container base and filter by group membership.
224+
parsed := parseBaseDN(base)
225+
226+
// Validate which CN components are actually groups (vs containers like CN=Users).
227+
confirmedGroups := validateGroupDNs(conn, parsed.potentialGroupDNs)
228+
229+
// Determine the effective base DN and membership filter.
230+
var baseDN, memberOfFilter string
231+
if len(confirmedGroups) > 0 {
232+
// We have confirmed groups - search from container and filter by membership.
233+
baseDN = parsed.containerBaseDN
234+
memberOfFilter = buildMemberOfFilter(confirmedGroups)
235+
} else {
236+
// No groups found - use the original base DN as-is.
237+
// This handles containers like CN=Users which should use subtree search.
238+
baseDN = parsed.originalBaseDN
239+
}
81240

82241
// Get groups in the directory. Get all groups independent of the
83242
// since parameter as they may not have changed for changed users.
@@ -92,9 +251,14 @@ func GetDetails(query, url, user, pass string, base *ldap.DN, since time.Time, u
92251
}
93252

94253
// Get users in the directory...
254+
// Build the user filter by combining the base query with any group
255+
// membership filter (from CN components in base DN) and time filter.
95256
userFilter := query
257+
if memberOfFilter != "" {
258+
userFilter = "(&" + query + memberOfFilter + ")"
259+
}
96260
if sinceFmtd != "" {
97-
userFilter = "(&" + query + "(whenChanged>=" + sinceFmtd + "))"
261+
userFilter = "(&" + userFilter + "(whenChanged>=" + sinceFmtd + "))"
98262
}
99263
usrs, err := search(conn, baseDN, userFilter, userAttrs, pagingSize)
100264
if err != nil {
@@ -126,7 +290,12 @@ func GetDetails(query, url, user, pass string, base *ldap.DN, since time.Time, u
126290
for i, u := range modGrps {
127291
modGrps[i] = "(memberOf=" + u + ")"
128292
}
129-
usrs, err := search(conn, baseDN, "(&"+query+"(|"+strings.Join(modGrps, "")+")", userAttrs, pagingSize)
293+
changedGrpFilter := "(&" + query + "(|" + strings.Join(modGrps, "") + "))"
294+
// Also include the base DN membership filter if present.
295+
if memberOfFilter != "" {
296+
changedGrpFilter = "(&" + changedGrpFilter + memberOfFilter + ")"
297+
}
298+
usrs, err := search(conn, baseDN, changedGrpFilter, userAttrs, pagingSize)
130299
if err != nil {
131300
errs = append(errs, fmt.Errorf("failed to collect users of changed groups%w: %w", ErrUsers, err))
132301
} else {

x-pack/filebeat/input/entityanalytics/provider/activedirectory/internal/activedirectory/activedirectory_test.go

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,141 @@ import (
1717

1818
var logResponses = flag.Bool("log_response", false, "use to log users/groups returned from the API")
1919

20+
func TestParseBaseDN(t *testing.T) {
21+
// The ldap library normalizes attribute types to lowercase when
22+
// serializing DNs, so expected values use lowercase cn, ou, dc.
23+
tests := []struct {
24+
name string
25+
baseDN string
26+
wantContainerBaseDN string
27+
wantPotentialGroupDNs []string
28+
wantOriginalBaseDN string
29+
}{
30+
{
31+
name: "OU only - no potential groups",
32+
baseDN: "OU=Users,DC=example,DC=com",
33+
wantContainerBaseDN: "ou=Users,dc=example,dc=com",
34+
wantPotentialGroupDNs: nil,
35+
wantOriginalBaseDN: "ou=Users,dc=example,dc=com",
36+
},
37+
{
38+
name: "DC only - no potential groups",
39+
baseDN: "DC=example,DC=com",
40+
wantContainerBaseDN: "dc=example,dc=com",
41+
wantPotentialGroupDNs: nil,
42+
wantOriginalBaseDN: "dc=example,dc=com",
43+
},
44+
{
45+
name: "CN before OU - extracts potential group",
46+
baseDN: "CN=Admin Users,OU=Groups,DC=example,DC=com",
47+
wantContainerBaseDN: "ou=Groups,dc=example,dc=com",
48+
wantPotentialGroupDNs: []string{"cn=Admin Users,ou=Groups,dc=example,dc=com"},
49+
wantOriginalBaseDN: "cn=Admin Users,ou=Groups,dc=example,dc=com",
50+
},
51+
{
52+
name: "CN before DC - extracts potential group",
53+
baseDN: "CN=Domain Admins,DC=example,DC=com",
54+
wantContainerBaseDN: "dc=example,dc=com",
55+
wantPotentialGroupDNs: []string{"cn=Domain Admins,dc=example,dc=com"},
56+
wantOriginalBaseDN: "cn=Domain Admins,dc=example,dc=com",
57+
},
58+
{
59+
name: "nested OU - no potential groups",
60+
baseDN: "OU=IT,OU=Departments,DC=example,DC=com",
61+
wantContainerBaseDN: "ou=IT,ou=Departments,dc=example,dc=com",
62+
wantPotentialGroupDNs: nil,
63+
wantOriginalBaseDN: "ou=IT,ou=Departments,dc=example,dc=com",
64+
},
65+
{
66+
name: "CN Users container - extracts as potential group",
67+
baseDN: "CN=Users,DC=example,DC=com",
68+
wantContainerBaseDN: "dc=example,dc=com",
69+
wantPotentialGroupDNs: []string{"cn=Users,dc=example,dc=com"},
70+
wantOriginalBaseDN: "cn=Users,dc=example,dc=com",
71+
},
72+
{
73+
name: "complex path with CN",
74+
baseDN: "CN=Security Team,OU=IT Groups,OU=Groups,DC=corp,DC=example,DC=com",
75+
wantContainerBaseDN: "ou=IT Groups,ou=Groups,dc=corp,dc=example,dc=com",
76+
wantPotentialGroupDNs: []string{"cn=Security Team,ou=IT Groups,ou=Groups,dc=corp,dc=example,dc=com"},
77+
wantOriginalBaseDN: "cn=Security Team,ou=IT Groups,ou=Groups,dc=corp,dc=example,dc=com",
78+
},
79+
}
80+
81+
for _, tt := range tests {
82+
t.Run(tt.name, func(t *testing.T) {
83+
base, err := ldap.ParseDN(tt.baseDN)
84+
if err != nil {
85+
t.Fatalf("failed to parse test DN %q: %v", tt.baseDN, err)
86+
}
87+
88+
got := parseBaseDN(base)
89+
90+
if got.containerBaseDN != tt.wantContainerBaseDN {
91+
t.Errorf("parseBaseDN() containerBaseDN = %q, want %q", got.containerBaseDN, tt.wantContainerBaseDN)
92+
}
93+
if got.originalBaseDN != tt.wantOriginalBaseDN {
94+
t.Errorf("parseBaseDN() originalBaseDN = %q, want %q", got.originalBaseDN, tt.wantOriginalBaseDN)
95+
}
96+
if len(got.potentialGroupDNs) != len(tt.wantPotentialGroupDNs) {
97+
t.Errorf("parseBaseDN() potentialGroupDNs length = %d, want %d", len(got.potentialGroupDNs), len(tt.wantPotentialGroupDNs))
98+
} else {
99+
for i, dn := range got.potentialGroupDNs {
100+
if dn != tt.wantPotentialGroupDNs[i] {
101+
t.Errorf("parseBaseDN() potentialGroupDNs[%d] = %q, want %q", i, dn, tt.wantPotentialGroupDNs[i])
102+
}
103+
}
104+
}
105+
})
106+
}
107+
}
108+
109+
func TestParseBaseDNNil(t *testing.T) {
110+
got := parseBaseDN(nil)
111+
if got.containerBaseDN != "" || got.originalBaseDN != "" || len(got.potentialGroupDNs) != 0 {
112+
t.Errorf("parseBaseDN(nil) = %+v, want empty struct", got)
113+
}
114+
115+
emptyDN := &ldap.DN{}
116+
got = parseBaseDN(emptyDN)
117+
if got.containerBaseDN != "" || got.originalBaseDN != "" || len(got.potentialGroupDNs) != 0 {
118+
t.Errorf("parseBaseDN(empty) = %+v, want empty struct", got)
119+
}
120+
}
121+
122+
func TestBuildMemberOfFilter(t *testing.T) {
123+
tests := []struct {
124+
name string
125+
groupDNs []string
126+
want string
127+
}{
128+
{
129+
name: "empty",
130+
groupDNs: nil,
131+
want: "",
132+
},
133+
{
134+
name: "single group",
135+
groupDNs: []string{"cn=Admin Users,ou=Groups,dc=example,dc=com"},
136+
want: "(memberOf=cn=Admin Users,ou=Groups,dc=example,dc=com)",
137+
},
138+
{
139+
name: "multiple groups",
140+
groupDNs: []string{"cn=Admins,dc=example,dc=com", "cn=Users,dc=example,dc=com"},
141+
want: "(|(memberOf=cn=Admins,dc=example,dc=com)(memberOf=cn=Users,dc=example,dc=com))",
142+
},
143+
}
144+
145+
for _, tt := range tests {
146+
t.Run(tt.name, func(t *testing.T) {
147+
got := buildMemberOfFilter(tt.groupDNs)
148+
if got != tt.want {
149+
t.Errorf("buildMemberOfFilter() = %q, want %q", got, tt.want)
150+
}
151+
})
152+
}
153+
}
154+
20155
// Invoke test with something like this:
21156
//
22157
// AD_BASE=CN=Users,DC=<servername>,DC=local AD_URL=ldap://<ip> AD_USER=CN=Administrator,CN=Users,DC=<servername>,DC=local AD_PASS=<passwort> go test -v -log_response

0 commit comments

Comments
 (0)