Skip to content

Commit 3af4072

Browse files
mergify[bot]efd6
andauthored
x-pack/filebeat/input/entityanalytics/provider/activedirectory: fix device attribute field layout (#50472) (#50558)
The Active Directory provider emitted device (computer) attributes under activedirectory.user.* instead of activedirectory.device.*, because collate() hardcoded the "user" wrapping key for all entity types. This caused the entityanalytics_ad device.yml ingest pipeline to fail on the dot-expander step since the expected device fields were absent. Add a Device field to Entry and thread an entTyp parameter through GetDetails and collate so that device results are wrapped under "device" and populate Entry.Device. User results are unchanged. Fixes #50471 Assisted-By: Cursor (cherry picked from commit 981bba9) Co-authored-by: Dan Kortschak <dan.kortschak@elastic.co>
1 parent 2935b37 commit 3af4072

4 files changed

Lines changed: 142 additions & 17 deletions

File tree

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
kind: bug-fix
2+
summary: Fix Active Directory entity analytics to emit device attributes under activedirectory.device.
3+
component: filebeat
4+
issue: https://github.com/elastic/beats/issues/50471

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -420,7 +420,7 @@ func (p *adInput) doFetchUsers(ctx context.Context, state *stateStore, fullSync
420420
if p.cfg.UserQuery != "" {
421421
query = p.cfg.UserQuery
422422
}
423-
entries, err := activedirectory.GetDetails(query, p.cfg.URL, p.cfg.User, p.cfg.Password, p.baseDN, since, p.cfg.UserAttrs, p.cfg.GrpAttrs, p.cfg.PagingSize, nil, p.tlsConfig)
423+
entries, err := activedirectory.GetDetails(query, p.cfg.URL, p.cfg.User, p.cfg.Password, p.baseDN, since, p.cfg.UserAttrs, p.cfg.GrpAttrs, p.cfg.PagingSize, nil, p.tlsConfig, "user")
424424
p.logger.Debugf("received %d users from API", len(entries))
425425
if err != nil {
426426
return nil, err
@@ -452,7 +452,7 @@ func (p *adInput) doFetchDevices(ctx context.Context, state *stateStore, fullSyn
452452
if p.cfg.DeviceQuery != "" {
453453
query = p.cfg.DeviceQuery
454454
}
455-
entries, err := activedirectory.GetDetails(query, p.cfg.URL, p.cfg.User, p.cfg.Password, p.baseDN, since, p.cfg.UserAttrs, p.cfg.GrpAttrs, p.cfg.PagingSize, nil, p.tlsConfig)
455+
entries, err := activedirectory.GetDetails(query, p.cfg.URL, p.cfg.User, p.cfg.Password, p.baseDN, since, p.cfg.UserAttrs, p.cfg.GrpAttrs, p.cfg.PagingSize, nil, p.tlsConfig, "device")
456456
p.logger.Debugf("received %d devices from API", len(entries))
457457
if err != nil {
458458
return nil, err

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

Lines changed: 34 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -167,12 +167,14 @@ func buildMemberOfFilter(groupDNs []string) string {
167167
}
168168

169169
// Entry is an Active Directory entry with associated group membership.
170-
// For user/device entries, User holds the entity attributes and Groups
171-
// holds resolved group memberships. For empty-group entries, Group holds
172-
// the group attributes and User and Groups are nil.
170+
// For user entries, User holds the entity attributes. For device
171+
// (computer) entries, Device holds the entity attributes. For
172+
// empty-group entries, Group holds the group attributes. Groups holds
173+
// resolved group memberships for user and device entries.
173174
type Entry struct {
174175
ID string `json:"id"`
175176
User map[string]any `json:"user,omitempty"`
177+
Device map[string]any `json:"device,omitempty"`
176178
Group map[string]any `json:"group,omitempty"`
177179
Groups []any `json:"groups,omitempty"`
178180
WhenChanged time.Time `json:"whenChanged"`
@@ -198,7 +200,15 @@ type Entry struct {
198200
// memberOf filters to find users who are members of those groups. This is
199201
// necessary because groups are leaf objects in LDAP and don't contain users
200202
// as children in the directory tree hierarchy.
201-
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) {
203+
//
204+
// entTyp controls which Entry field receives the entity attributes:
205+
// "user" populates Entry.User, "device" populates Entry.Device.
206+
func GetDetails(query, url, user, pass string, base *ldap.DN, since time.Time, userAttrs, grpAttrs []string, pagingSize uint32, dialer *net.Dialer, tlsconfig *tls.Config, entTyp string) ([]Entry, error) {
207+
switch entTyp {
208+
case "user", "device":
209+
default:
210+
return nil, fmt.Errorf("invalid entity type: %q", entTyp)
211+
}
202212
if base == nil || len(base.RDNs) == 0 {
203213
return nil, fmt.Errorf("%w: no path", ErrInvalidDistinguishedName)
204214
}
@@ -259,7 +269,7 @@ func GetDetails(query, url, user, pass string, base *ldap.DN, since time.Time, u
259269
errs = []error{fmt.Errorf("%w: %w", ErrGroups, err)}
260270
groups.Entries = entries{}
261271
} else {
262-
groups = collate(grps, nil)
272+
groups = collate(grps, nil, "")
263273
}
264274

265275
// Get users in the directory...
@@ -278,7 +288,7 @@ func GetDetails(query, url, user, pass string, base *ldap.DN, since time.Time, u
278288
return nil, errors.Join(errs...)
279289
}
280290
// ...and apply group membership.
281-
users := collate(usrs, groups.Entries)
291+
users := collate(usrs, groups.Entries, entTyp)
282292

283293
// Also collect users that are members of groups that have changed.
284294
if sinceFmtd != "" {
@@ -287,7 +297,7 @@ func GetDetails(query, url, user, pass string, base *ldap.DN, since time.Time, u
287297
// Allow continuation if groups query fails, but warn.
288298
errs = append(errs, fmt.Errorf("failed to collect changed groups: %w: %w", ErrGroups, err))
289299
} else {
290-
groups := collate(grps, nil)
300+
groups := collate(grps, nil, "")
291301

292302
// Get users of the changed groups
293303
var modGrps []string
@@ -316,7 +326,7 @@ func GetDetails(query, url, user, pass string, base *ldap.DN, since time.Time, u
316326
} else {
317327
// ...and apply group membership, inserting into users
318328
// if not present.
319-
for dn, u := range collate(usrs, groups.Entries).Entries {
329+
for dn, u := range collate(usrs, groups.Entries, entTyp).Entries {
320330
_, ok := users.Entries[dn]
321331
if ok {
322332
continue
@@ -331,15 +341,24 @@ func GetDetails(query, url, user, pass string, base *ldap.DN, since time.Time, u
331341
// Assemble into a set of documents.
332342
docs := make([]Entry, 0, len(users.Entries))
333343
for id, u := range users.Entries {
334-
user := u["user"].(map[string]any)
344+
attrs := u[entTyp].(map[string]any)
335345
var groups []any
336346
switch g := u["groups"].(type) {
337347
case nil:
338348
case []any:
339349
// Do not bother concretising these.
340350
groups = g
341351
}
342-
docs = append(docs, Entry{ID: id, User: user, Groups: groups, WhenChanged: whenChanged(user, groups)})
352+
e := Entry{ID: id, Groups: groups, WhenChanged: whenChanged(attrs, groups)}
353+
switch entTyp {
354+
case "user":
355+
e.User = attrs
356+
case "device":
357+
e.Device = attrs
358+
default:
359+
panic("unreachable")
360+
}
361+
docs = append(docs, e)
343362
}
344363
return docs, errors.Join(errs...)
345364
}
@@ -388,7 +407,7 @@ func GetEmptyGroups(url, user, pass string, base *ldap.DN, since time.Time, grpA
388407
return nil, fmt.Errorf("%w: %w", ErrGroups, err)
389408
}
390409

391-
groups := collate(result, nil)
410+
groups := collate(result, nil, "")
392411
docs := make([]Entry, 0, len(groups.Entries))
393412
for _, g := range groups.Entries {
394413
dn, _ := g["distinguishedName"].(string)
@@ -451,15 +470,17 @@ type directory struct {
451470
// group information if it is available. Fields with known types will be converted
452471
// from strings to the known type.
453472
// Also included in the returned map is the sets of referrals and controls.
454-
func collate(resp *ldap.SearchResult, groups entries) directory {
473+
// entTyp labels the entity attributes when group membership is being
474+
// resolved (e.g. "user" or "device"); it is ignored when groups is nil.
475+
func collate(resp *ldap.SearchResult, groups entries, entTyp string) directory {
455476
dir := directory{
456477
Entries: make(entries),
457478
}
458479
for _, e := range resp.Entries {
459480
u := make(map[string]any)
460481
m := u
461482
if groups != nil {
462-
m = map[string]any{"user": u}
483+
m = map[string]any{entTyp: u}
463484
}
464485
for _, attr := range e.Attributes {
465486
val := entype(attr)

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

Lines changed: 102 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -180,7 +180,7 @@ func Test(t *testing.T) {
180180

181181
var times []time.Time
182182
t.Run("full", func(t *testing.T) {
183-
users, err := GetDetails("(&(objectCategory=person)(objectClass=user))", url, user, pass, base, time.Time{}, nil, nil, 0, nil, nil)
183+
users, err := GetDetails("(&(objectCategory=person)(objectClass=user))", url, user, pass, base, time.Time{}, nil, nil, 0, nil, nil, "user")
184184
if err != nil {
185185
t.Fatalf("unexpected error from GetDetails: %v", err)
186186
}
@@ -226,7 +226,7 @@ func Test(t *testing.T) {
226226
want++
227227
}
228228
}
229-
users, err := GetDetails("(&(objectCategory=person)(objectClass=user))", url, user, pass, base, since, nil, nil, 0, nil, nil)
229+
users, err := GetDetails("(&(objectCategory=person)(objectClass=user))", url, user, pass, base, since, nil, nil, 0, nil, nil, "user")
230230
if err != nil {
231231
t.Fatalf("unexpected error from GetDetails: %v", err)
232232
}
@@ -273,3 +273,103 @@ func Test(t *testing.T) {
273273
t.Logf("empty groups: %s", b)
274274
})
275275
}
276+
277+
func TestGetDetailsInvalidEntTyp(t *testing.T) {
278+
base, err := ldap.ParseDN("DC=example,DC=com")
279+
if err != nil {
280+
t.Fatalf("failed to parse DN: %v", err)
281+
}
282+
_, err = GetDetails("(objectClass=*)", "ldap://localhost", "", "", base, time.Time{}, nil, nil, 0, nil, nil, "bogus")
283+
if err == nil {
284+
t.Fatal("expected error for invalid entTyp")
285+
}
286+
if got := err.Error(); got != `invalid entity type: "bogus"` {
287+
t.Errorf("unexpected error message: %s", got)
288+
}
289+
}
290+
291+
func TestEntryDeviceFieldJSON(t *testing.T) {
292+
e := Entry{
293+
ID: "cn=host1,dc=example,dc=com",
294+
Device: map[string]any{"cn": "host1"},
295+
Groups: []any{map[string]any{"cn": "Admins"}},
296+
}
297+
b, err := json.Marshal(e)
298+
if err != nil {
299+
t.Fatalf("unexpected marshal error: %v", err)
300+
}
301+
var m map[string]any
302+
if err := json.Unmarshal(b, &m); err != nil {
303+
t.Fatalf("unexpected unmarshal error: %v", err)
304+
}
305+
if _, ok := m["device"]; !ok {
306+
t.Error("expected 'device' key in marshaled Entry")
307+
}
308+
if _, ok := m["user"]; ok {
309+
t.Error("unexpected 'user' key in marshaled Entry with nil User")
310+
}
311+
}
312+
313+
func TestCollateEntityKey(t *testing.T) {
314+
groups := entries{
315+
"cn=Admins,dc=example,dc=com": map[string]any{
316+
"cn": "Admins",
317+
},
318+
}
319+
320+
resp := &ldap.SearchResult{
321+
Entries: []*ldap.Entry{
322+
{
323+
DN: "cn=host1,dc=example,dc=com",
324+
Attributes: []*ldap.EntryAttribute{
325+
{Name: "cn", Values: []string{"host1"}},
326+
{Name: "memberOf", Values: []string{"cn=Admins,dc=example,dc=com"}},
327+
},
328+
},
329+
},
330+
}
331+
332+
t.Run("user", func(t *testing.T) {
333+
dir := collate(resp, groups, "user")
334+
entry, ok := dir.Entries["cn=host1,dc=example,dc=com"]
335+
if !ok {
336+
t.Fatal("expected entry for cn=host1")
337+
}
338+
if _, ok := entry["user"]; !ok {
339+
t.Error("expected 'user' key in collated entry")
340+
}
341+
if _, ok := entry["device"]; ok {
342+
t.Error("unexpected 'device' key in collated entry")
343+
}
344+
})
345+
346+
t.Run("device", func(t *testing.T) {
347+
dir := collate(resp, groups, "device")
348+
entry, ok := dir.Entries["cn=host1,dc=example,dc=com"]
349+
if !ok {
350+
t.Fatal("expected entry for cn=host1")
351+
}
352+
if _, ok := entry["device"]; !ok {
353+
t.Error("expected 'device' key in collated entry")
354+
}
355+
if _, ok := entry["user"]; ok {
356+
t.Error("unexpected 'user' key in collated entry")
357+
}
358+
})
359+
360+
t.Run("groups_resolved", func(t *testing.T) {
361+
dir := collate(resp, groups, "device")
362+
entry := dir.Entries["cn=host1,dc=example,dc=com"]
363+
grps, ok := entry["groups"]
364+
if !ok {
365+
t.Fatal("expected 'groups' key in collated entry")
366+
}
367+
grpSlice, ok := grps.([]any)
368+
if !ok {
369+
t.Fatalf("expected groups to be []any, got %T", grps)
370+
}
371+
if len(grpSlice) != 1 {
372+
t.Fatalf("expected 1 group, got %d", len(grpSlice))
373+
}
374+
})
375+
}

0 commit comments

Comments
 (0)