Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -167,9 +167,12 @@
}

// 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"`
}
Expand All @@ -194,7 +197,15 @@
// 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)
}
Expand All @@ -215,7 +226,7 @@
if err != nil {
return nil, err
}
defer conn.Unbind()

Check failure on line 229 in x-pack/filebeat/input/entityanalytics/provider/activedirectory/internal/activedirectory/activedirectory.go

View workflow job for this annotation

GitHub Actions / lint (ubuntu-latest)

Error return value of `conn.Unbind` is not checked (errcheck)

var errs []error

Expand Down Expand Up @@ -255,7 +266,7 @@
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...
Expand All @@ -274,7 +285,7 @@
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 != "" {
Expand All @@ -283,7 +294,7 @@
// 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
Expand Down Expand Up @@ -312,7 +323,7 @@
} 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
Expand All @@ -327,15 +338,24 @@
// 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)

Check failure on line 341 in x-pack/filebeat/input/entityanalytics/provider/activedirectory/internal/activedirectory/activedirectory.go

View workflow job for this annotation

GitHub Actions / lint (ubuntu-latest)

Error return value is not checked (errcheck)
var groups []any
switch g := u["groups"].(type) {
case nil:
case []any:
// 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...)
}
Expand Down Expand Up @@ -393,15 +413,17 @@
// 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),
}
for _, e := range resp.Entries {
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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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)
}
Expand All @@ -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))
}
})
}
Loading