diff --git a/changelog/fragments/1777850001-fix-ad-device-field-layout.yaml b/changelog/fragments/1777850001-fix-ad-device-field-layout.yaml new file mode 100644 index 000000000000..91bd02b16f16 --- /dev/null +++ b/changelog/fragments/1777850001-fix-ad-device-field-layout.yaml @@ -0,0 +1,4 @@ +kind: bug-fix +summary: Fix Active Directory entity analytics to emit device attributes under activedirectory.device. +component: filebeat +issue: https://github.com/elastic/beats/issues/50471 diff --git a/x-pack/filebeat/input/entityanalytics/provider/activedirectory/activedirectory.go b/x-pack/filebeat/input/entityanalytics/provider/activedirectory/activedirectory.go index 6730ed6706ea..cf2e207d2c3b 100644 --- a/x-pack/filebeat/input/entityanalytics/provider/activedirectory/activedirectory.go +++ b/x-pack/filebeat/input/entityanalytics/provider/activedirectory/activedirectory.go @@ -401,7 +401,7 @@ func (p *adInput) doFetchUsers(ctx context.Context, state *stateStore, fullSync if p.cfg.UserQuery != "" { query = p.cfg.UserQuery } - 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) + 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") p.logger.Debugf("received %d users from API", len(entries)) if err != nil { return nil, err @@ -433,7 +433,7 @@ func (p *adInput) doFetchDevices(ctx context.Context, state *stateStore, fullSyn if p.cfg.DeviceQuery != "" { query = p.cfg.DeviceQuery } - 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) + 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") p.logger.Debugf("received %d devices from API", len(entries)) if err != nil { return nil, err diff --git a/x-pack/filebeat/input/entityanalytics/provider/activedirectory/internal/activedirectory/activedirectory.go b/x-pack/filebeat/input/entityanalytics/provider/activedirectory/internal/activedirectory/activedirectory.go index c5e00ad48dfe..32846435bec1 100644 --- a/x-pack/filebeat/input/entityanalytics/provider/activedirectory/internal/activedirectory/activedirectory.go +++ b/x-pack/filebeat/input/entityanalytics/provider/activedirectory/internal/activedirectory/activedirectory.go @@ -167,9 +167,12 @@ func buildMemberOfFilter(groupDNs []string) string { } // Entry is an Active Directory user entry with associated group membership. +// For device (computer) entries, Device holds the entity attributes. Groups +// holds resolved group memberships for user and device entries. type Entry struct { ID string `json:"id"` - User map[string]any `json:"user"` + User map[string]any `json:"user,omitempty"` + Device map[string]any `json:"device,omitempty"` Groups []any `json:"groups,omitempty"` WhenChanged time.Time `json:"whenChanged"` } @@ -194,7 +197,15 @@ type Entry struct { // memberOf filters to find users who are members of those groups. This is // necessary because groups are leaf objects in LDAP and don't contain users // as children in the directory tree hierarchy. -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) { +// +// entTyp controls which Entry field receives the entity attributes: +// "user" populates Entry.User, "device" populates Entry.Device. +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) { + switch entTyp { + case "user", "device": + default: + return nil, fmt.Errorf("invalid entity type: %q", entTyp) + } if base == nil || len(base.RDNs) == 0 { return nil, fmt.Errorf("%w: no path", ErrInvalidDistinguishedName) } @@ -255,7 +266,7 @@ func GetDetails(query, url, user, pass string, base *ldap.DN, since time.Time, u errs = []error{fmt.Errorf("%w: %w", ErrGroups, err)} groups.Entries = entries{} } else { - groups = collate(grps, nil) + groups = collate(grps, nil, "") } // Get users in the directory... @@ -274,7 +285,7 @@ func GetDetails(query, url, user, pass string, base *ldap.DN, since time.Time, u return nil, errors.Join(errs...) } // ...and apply group membership. - users := collate(usrs, groups.Entries) + users := collate(usrs, groups.Entries, entTyp) // Also collect users that are members of groups that have changed. if sinceFmtd != "" { @@ -283,7 +294,7 @@ func GetDetails(query, url, user, pass string, base *ldap.DN, since time.Time, u // Allow continuation if groups query fails, but warn. errs = append(errs, fmt.Errorf("failed to collect changed groups: %w: %w", ErrGroups, err)) } else { - groups := collate(grps, nil) + groups := collate(grps, nil, "") // Get users of the changed groups var modGrps []string @@ -312,7 +323,7 @@ func GetDetails(query, url, user, pass string, base *ldap.DN, since time.Time, u } else { // ...and apply group membership, inserting into users // if not present. - for dn, u := range collate(usrs, groups.Entries).Entries { + for dn, u := range collate(usrs, groups.Entries, entTyp).Entries { _, ok := users.Entries[dn] if ok { continue @@ -327,7 +338,7 @@ func GetDetails(query, url, user, pass string, base *ldap.DN, since time.Time, u // Assemble into a set of documents. docs := make([]Entry, 0, len(users.Entries)) for id, u := range users.Entries { - user := u["user"].(map[string]any) + attrs := u[entTyp].(map[string]any) var groups []any switch g := u["groups"].(type) { case nil: @@ -335,7 +346,16 @@ func GetDetails(query, url, user, pass string, base *ldap.DN, since time.Time, u // Do not bother concretising these. groups = g } - docs = append(docs, Entry{ID: id, User: user, Groups: groups, WhenChanged: whenChanged(user, groups)}) + e := Entry{ID: id, Groups: groups, WhenChanged: whenChanged(attrs, groups)} + switch entTyp { + case "user": + e.User = attrs + case "device": + e.Device = attrs + default: + panic("unreachable") + } + docs = append(docs, e) } return docs, errors.Join(errs...) } @@ -393,7 +413,9 @@ type directory struct { // group information if it is available. Fields with known types will be converted // from strings to the known type. // Also included in the returned map is the sets of referrals and controls. -func collate(resp *ldap.SearchResult, groups entries) directory { +// entTyp labels the entity attributes when group membership is being +// resolved (e.g. "user" or "device"); it is ignored when groups is nil. +func collate(resp *ldap.SearchResult, groups entries, entTyp string) directory { dir := directory{ Entries: make(entries), } @@ -401,7 +423,7 @@ func collate(resp *ldap.SearchResult, groups entries) directory { u := make(map[string]any) m := u if groups != nil { - m = map[string]any{"user": u} + m = map[string]any{entTyp: u} } for _, attr := range e.Attributes { val := entype(attr) diff --git a/x-pack/filebeat/input/entityanalytics/provider/activedirectory/internal/activedirectory/activedirectory_test.go b/x-pack/filebeat/input/entityanalytics/provider/activedirectory/internal/activedirectory/activedirectory_test.go index fa4d51e39f1b..1e36607541f9 100644 --- a/x-pack/filebeat/input/entityanalytics/provider/activedirectory/internal/activedirectory/activedirectory_test.go +++ b/x-pack/filebeat/input/entityanalytics/provider/activedirectory/internal/activedirectory/activedirectory_test.go @@ -180,7 +180,7 @@ func Test(t *testing.T) { var times []time.Time t.Run("full", func(t *testing.T) { - users, err := GetDetails("(&(objectCategory=person)(objectClass=user))", url, user, pass, base, time.Time{}, nil, nil, 0, nil, nil) + users, err := GetDetails("(&(objectCategory=person)(objectClass=user))", url, user, pass, base, time.Time{}, nil, nil, 0, nil, nil, "user") if err != nil { t.Fatalf("unexpected error from GetDetails: %v", err) } @@ -226,7 +226,7 @@ func Test(t *testing.T) { want++ } } - users, err := GetDetails("(&(objectCategory=person)(objectClass=user))", url, user, pass, base, since, nil, nil, 0, nil, nil) + users, err := GetDetails("(&(objectCategory=person)(objectClass=user))", url, user, pass, base, since, nil, nil, 0, nil, nil, "user") if err != nil { t.Fatalf("unexpected error from GetDetails: %v", err) } @@ -245,3 +245,103 @@ func Test(t *testing.T) { t.Logf("user: %s", b) }) } + +func TestGetDetailsInvalidEntTyp(t *testing.T) { + base, err := ldap.ParseDN("DC=example,DC=com") + if err != nil { + t.Fatalf("failed to parse DN: %v", err) + } + _, err = GetDetails("(objectClass=*)", "ldap://localhost", "", "", base, time.Time{}, nil, nil, 0, nil, nil, "bogus") + if err == nil { + t.Fatal("expected error for invalid entTyp") + } + if got := err.Error(); got != `invalid entity type: "bogus"` { + t.Errorf("unexpected error message: %s", got) + } +} + +func TestEntryDeviceFieldJSON(t *testing.T) { + e := Entry{ + ID: "cn=host1,dc=example,dc=com", + Device: map[string]any{"cn": "host1"}, + Groups: []any{map[string]any{"cn": "Admins"}}, + } + b, err := json.Marshal(e) + if err != nil { + t.Fatalf("unexpected marshal error: %v", err) + } + var m map[string]any + if err := json.Unmarshal(b, &m); err != nil { + t.Fatalf("unexpected unmarshal error: %v", err) + } + if _, ok := m["device"]; !ok { + t.Error("expected 'device' key in marshaled Entry") + } + if _, ok := m["user"]; ok { + t.Error("unexpected 'user' key in marshaled Entry with nil User") + } +} + +func TestCollateEntityKey(t *testing.T) { + groups := entries{ + "cn=Admins,dc=example,dc=com": map[string]any{ + "cn": "Admins", + }, + } + + resp := &ldap.SearchResult{ + Entries: []*ldap.Entry{ + { + DN: "cn=host1,dc=example,dc=com", + Attributes: []*ldap.EntryAttribute{ + {Name: "cn", Values: []string{"host1"}}, + {Name: "memberOf", Values: []string{"cn=Admins,dc=example,dc=com"}}, + }, + }, + }, + } + + t.Run("user", func(t *testing.T) { + dir := collate(resp, groups, "user") + entry, ok := dir.Entries["cn=host1,dc=example,dc=com"] + if !ok { + t.Fatal("expected entry for cn=host1") + } + if _, ok := entry["user"]; !ok { + t.Error("expected 'user' key in collated entry") + } + if _, ok := entry["device"]; ok { + t.Error("unexpected 'device' key in collated entry") + } + }) + + t.Run("device", func(t *testing.T) { + dir := collate(resp, groups, "device") + entry, ok := dir.Entries["cn=host1,dc=example,dc=com"] + if !ok { + t.Fatal("expected entry for cn=host1") + } + if _, ok := entry["device"]; !ok { + t.Error("expected 'device' key in collated entry") + } + if _, ok := entry["user"]; ok { + t.Error("unexpected 'user' key in collated entry") + } + }) + + t.Run("groups_resolved", func(t *testing.T) { + dir := collate(resp, groups, "device") + entry := dir.Entries["cn=host1,dc=example,dc=com"] + grps, ok := entry["groups"] + if !ok { + t.Fatal("expected 'groups' key in collated entry") + } + grpSlice, ok := grps.([]any) + if !ok { + t.Fatalf("expected groups to be []any, got %T", grps) + } + if len(grpSlice) != 1 { + t.Fatalf("expected 1 group, got %d", len(grpSlice)) + } + }) +}