From 818030fffb84d4c94db45f99615e71e9b9b88d2d Mon Sep 17 00:00:00 2001 From: chemamartinez Date: Tue, 31 Mar 2026 16:38:17 +0200 Subject: [PATCH 1/3] Collect enrolled devices for users in Okta entity analytics provider --- .../filebeat-input-entity-analytics.md | 4 +- .../entityanalytics/provider/okta/conf.go | 2 +- .../provider/okta/internal/okta/okta.go | 23 +++++++++++ .../entityanalytics/provider/okta/okta.go | 9 +++++ .../provider/okta/okta_test.go | 38 ++++++++++++++----- .../provider/okta/statestore.go | 1 + 6 files changed, 65 insertions(+), 12 deletions(-) diff --git a/docs/reference/filebeat/filebeat-input-entity-analytics.md b/docs/reference/filebeat/filebeat-input-entity-analytics.md index 3878549d84d9..96f646a410de 100644 --- a/docs/reference/filebeat/filebeat-input-entity-analytics.md +++ b/docs/reference/filebeat/filebeat-input-entity-analytics.md @@ -1231,7 +1231,9 @@ The datasets to collect from the API. This can be one of "all", "users" or "devi #### `enrich_with` [_enrich_with] -The metadata to enrich users with. This is an array of values that may contain "groups", "roles" and "factors", or "none". If the array only contains "none", no metadata is collected for users. The default behavior is to collect "groups". +The metadata to enrich users with. This is an array of values that may contain "groups", "roles", "factors" and "enrolled_devices", or "none". If the array only contains "none", no metadata is collected for users. The default behavior is to collect "groups". + +When "enrolled_devices" is included, each user is enriched with the list of devices enrolled for that user by calling the [List User Devices](https://developer.okta.com/docs/api/openapi/okta-management/management/tags/userresources/other/listuserdevices) API. This requires one additional API request per user, so it is disabled by default to avoid hitting Okta rate limits. #### `sync_interval` [_sync_interval_4] diff --git a/x-pack/filebeat/input/entityanalytics/provider/okta/conf.go b/x-pack/filebeat/input/entityanalytics/provider/okta/conf.go index 587dde04c8a7..3a66a0ee4d89 100644 --- a/x-pack/filebeat/input/entityanalytics/provider/okta/conf.go +++ b/x-pack/filebeat/input/entityanalytics/provider/okta/conf.go @@ -62,7 +62,7 @@ type conf struct { Dataset string `config:"dataset"` // EnrichWith specifies the additional data that // will be used to enrich user data. It can include - // "groups", "roles" and "factors". + // "groups", "roles", "factors" and "enrolled_devices". // If it is a single element with "none", no // enrichment is performed. EnrichWith []string `config:"enrich_with"` diff --git a/x-pack/filebeat/input/entityanalytics/provider/okta/internal/okta/okta.go b/x-pack/filebeat/input/entityanalytics/provider/okta/internal/okta/okta.go index a9245d39104f..054b8941be4f 100644 --- a/x-pack/filebeat/input/entityanalytics/provider/okta/internal/okta/okta.go +++ b/x-pack/filebeat/input/entityanalytics/provider/okta/internal/okta/okta.go @@ -276,6 +276,29 @@ func GetUserGroupDetails(ctx context.Context, cli *http.Client, host, key, user return getDetails[Group](ctx, cli, u, endpoint, key, true, OmitNone, lim, log) } +// GetUserDevices returns Okta device details for devices enrolled by the provided user +// using the list user devices API. host is the Okta user domain and key is the API +// token to use for the query. user must not be empty. +// +// See GetUserDetails for details of the query and rate limit parameters. +// +// See https://developer.okta.com/docs/api/openapi/okta-management/management/tags/userresources/other/listuserdevices for details. +func GetUserDevices(ctx context.Context, cli *http.Client, host, key, user string, lim *RateLimiter, log *logp.Logger) ([]Device, http.Header, error) { + if user == "" { + return nil, nil, errors.New("no user specified") + } + + const endpoint = "/api/v1/users/{user}/devices" + path := strings.Replace(endpoint, "{user}", user, 1) + + u := &url.URL{ + Scheme: "https", + Host: host, + Path: path, + } + return getDetails[Device](ctx, cli, u, endpoint, key, true, OmitNone, lim, log) +} + // GetGroupRoles returns Okta group roles using the groups API endpoint. host is the // Okta user domain and key is the API token to use for the query. group must not be empty. // diff --git a/x-pack/filebeat/input/entityanalytics/provider/okta/okta.go b/x-pack/filebeat/input/entityanalytics/provider/okta/okta.go index 6114145faa30..97e77e2e7e96 100644 --- a/x-pack/filebeat/input/entityanalytics/provider/okta/okta.go +++ b/x-pack/filebeat/input/entityanalytics/provider/okta/okta.go @@ -579,6 +579,14 @@ func (p *oktaInput) addUserMetadata(ctx context.Context, u okta.User, state *sta su.Roles = roles } } + if slices.Contains(p.cfg.EnrichWith, "enrolled_devices") { + devices, _, err := okta.GetUserDevices(ctx, p.client, p.cfg.OktaDomain, p.getAuthToken(), u.ID, p.lim, p.logger) + if err != nil { + p.logger.Warnf("failed to get enrolled devices for user %s: %v", u.ID, err) + } else { + su.Devices = devices + } + } return su } @@ -770,6 +778,7 @@ func (p *oktaInput) publishUser(u *User, state *stateStore, inputID string, clie _, _ = userDoc.Put("groups", u.Groups) _, _ = userDoc.Put("roles", u.Roles) _, _ = userDoc.Put("factors", u.Factors) + _, _ = userDoc.Put("devices", u.Devices) switch u.State { case Deleted: diff --git a/x-pack/filebeat/input/entityanalytics/provider/okta/okta_test.go b/x-pack/filebeat/input/entityanalytics/provider/okta/okta_test.go index 5e9974ee6b8a..c17131d47971 100644 --- a/x-pack/filebeat/input/entityanalytics/provider/okta/okta_test.go +++ b/x-pack/filebeat/input/entityanalytics/provider/okta/okta_test.go @@ -39,6 +39,7 @@ func TestOktaDoFetch(t *testing.T) { {dataset: "all", enrichWith: []string{"groups"}, wantUsers: true, wantDevices: true}, {dataset: "users", enrichWith: []string{"groups", "roles", "factors"}, wantUsers: true, wantDevices: false}, {dataset: "devices", enrichWith: []string{"groups"}, wantUsers: false, wantDevices: true}, + {dataset: "users", enrichWith: []string{"groups", "enrolled_devices"}, wantUsers: true, wantDevices: false}, } for _, test := range tests { @@ -61,14 +62,17 @@ func TestOktaDoFetch(t *testing.T) { groups = `[{"id":"USERID","profile":{"description":"All users in your organization","name":"Everyone"}}]` factors = `[{"id":"ufs2bysphxKODSZKWVCT","factorType":"question","provider":"OKTA","vendorName":"OKTA","status":"ACTIVE","created":"2014-04-15T18:10:06.000Z","lastUpdated":"2014-04-15T18:10:06.000Z","profile":{"question":"favorite_art_piece","questionText":"What is your favorite piece of art?"}},{"id":"ostf2gsyictRQDSGTDZE","factorType":"token:software:totp","provider":"OKTA","status":"PENDING_ACTIVATION","created":"2014-06-27T20:27:33.000Z","lastUpdated":"2014-06-27T20:27:33.000Z","profile":{"credentialId":"dade.murphy@example.com"}},{"id":"sms2gt8gzgEBPUWBIFHN","factorType":"sms","provider":"OKTA","status":"ACTIVE","created":"2014-06-27T20:27:26.000Z","lastUpdated":"2014-06-27T20:27:26.000Z","profile":{"phoneNumber":"+1-555-415-1337"}}]` devices = `[{"id":"DEVICEID","status":"STATUS","created":"2019-10-02T18:03:07.000Z","lastUpdated":"2019-10-02T18:03:07.000Z","profile":{"displayName":"Example Device name 1","platform":"WINDOWS","serialNumber":"XXDDRFCFRGF3M8MD6D","sid":"S-1-11-111","registered":true,"secureHardwarePresent":false,"diskEncryptionType":"ALL_INTERNAL_VOLUMES"},"resourceType":"UDDevice","resourceDisplayName":{"value":"Example Device name 1","sensitive":false},"resourceAlternateId":null,"resourceId":"DEVICEID","_links":{"activate":{"href":"https://localhost/api/v1/devices/DEVICEID/lifecycle/activate","hints":{"allow":["POST"]}},"self":{"href":"https://localhost/api/v1/devices/DEVICEID","hints":{"allow":["GET","PATCH","PUT"]}},"users":{"href":"https://localhost/api/v1/devices/DEVICEID/users","hints":{"allow":["GET"]}}}}]` + // userDevices is sample data from https://developer.okta.com/docs/api/openapi/okta-management/management/tags/userresources/other/listuserdevices + userDevices = `[{"id":"guo4a5uyerdpvAiJT0h7","status":"ACTIVE","created":"2022-05-14T13:37:20.000Z","lastUpdated":"2022-05-14T13:37:20.000Z","profile":{"displayName":"DESKTOP-XXXX","platform":"WINDOWS","manufacturer":"LENOVO","model":"20BH002DUS","osVersion":"10.0.19043","serialNumber":"1XXXX0X0X","registered":true,"secureHardwarePresent":false,"diskEncryptionType":"ALL_INTERNAL_VOLUMES"},"resourceType":"UDDevice","resourceDisplayName":{"value":"DESKTOP-XXXX","sensitive":false},"resourceAlternateId":null,"resourceId":"guo4a5uyerdpvAiJT0h7","_links":{"activate":{"href":"https://localhost/api/v1/devices/guo4a5uyerdpvAiJT0h7/lifecycle/activate","hints":{"allow":["POST"]}},"self":{"href":"https://localhost/api/v1/devices/guo4a5uyerdpvAiJT0h7","hints":{"allow":["GET","PATCH","PUT"]}}}}]` ) data := map[string]string{ - "users": users, - "roles": roles, - "groups": groups, - "devices": devices, - "factors": factors, + "users": users, + "roles": roles, + "groups": groups, + "devices": devices, + "factors": factors, + "user_devices": userDevices, } var wantUsers []User @@ -107,6 +111,13 @@ func TestOktaDoFetch(t *testing.T) { t.Fatalf("failed to unmarshal role data: %v", err) } } + var wantUserDevices []okta.Device + if slices.Contains(test.enrichWith, "enrolled_devices") { + err := json.Unmarshal([]byte(userDevices), &wantUserDevices) + if err != nil { + t.Fatalf("failed to unmarshal user device data: %v", err) + } + } wantStates := make(map[string]State) @@ -123,13 +134,17 @@ func TestOktaDoFetch(t *testing.T) { mux.Handle("/api/v1/users/{userid}/{metadata}", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { setHeaders(w) attr := r.PathValue("metadata") - if attr != "groups" { + switch attr { + case "groups": + // Replace USERID placeholder with the actual user ID. + userid := r.PathValue("userid") + fmt.Fprintln(w, strings.ReplaceAll(data[attr], "USERID", userid)) + case "devices": + // User-enrolled devices are served from a separate data key. + fmt.Fprintln(w, data["user_devices"]) + default: fmt.Fprintln(w, data[attr]) - return } - // Give the groups if this is a get user groups request. - userid := r.PathValue("userid") - fmt.Fprintln(w, strings.ReplaceAll(data[attr], "USERID", userid)) })) mux.Handle("/api/v1/devices/{deviceid}/users", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { setHeaders(w) @@ -236,6 +251,9 @@ func TestOktaDoFetch(t *testing.T) { if len(g.Roles) != len(wantRoles) { t.Errorf("number of roles for user %d: got:%d want:%d", i, len(g.Roles), len(wantRoles)) } + if len(g.Devices) != len(wantUserDevices) { + t.Errorf("number of enrolled devices for user %d: got:%d want:%d", i, len(g.Devices), len(wantUserDevices)) + } for j, gg := range g.Groups { if gg.ID != wantID { t.Errorf("unexpected used ID for user group %d in %d: got:%s want:%s", j, i, gg.ID, wantID) diff --git a/x-pack/filebeat/input/entityanalytics/provider/okta/statestore.go b/x-pack/filebeat/input/entityanalytics/provider/okta/statestore.go index 296cbed5a408..801903ba2a92 100644 --- a/x-pack/filebeat/input/entityanalytics/provider/okta/statestore.go +++ b/x-pack/filebeat/input/entityanalytics/provider/okta/statestore.go @@ -40,6 +40,7 @@ type User struct { Groups []okta.Group `json:"groups"` Roles []okta.Role `json:"roles"` Factors []okta.Factor `json:"factors"` + Devices []okta.Device `json:"devices"` State State `json:"state"` } From 999b700bcb9918a250a87d290760f000376e56ff Mon Sep 17 00:00:00 2001 From: chemamartinez Date: Tue, 31 Mar 2026 16:47:17 +0200 Subject: [PATCH 2/3] Add changelog fragment --- ...74968309-okta-enrich-enrolled-devices.yaml | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 changelog/fragments/1774968309-okta-enrich-enrolled-devices.yaml diff --git a/changelog/fragments/1774968309-okta-enrich-enrolled-devices.yaml b/changelog/fragments/1774968309-okta-enrich-enrolled-devices.yaml new file mode 100644 index 000000000000..a28cc47bfb21 --- /dev/null +++ b/changelog/fragments/1774968309-okta-enrich-enrolled-devices.yaml @@ -0,0 +1,50 @@ +# REQUIRED +# Kind can be one of: +# - breaking-change: a change to previously-documented behavior +# - deprecation: functionality that is being removed in a later release +# - bug-fix: fixes a problem in a previous version +# - enhancement: extends functionality but does not break or fix existing behavior +# - feature: new functionality +# - known-issue: problems that we are aware of in a given version +# - security: impacts on the security of a product or a user's deployment. +# - upgrade: important information for someone upgrading from a prior version +# - other: does not fit into any of the other categories +kind: enhancement + +# REQUIRED for all kinds +# Change summary; a 80ish characters long description of the change. +summary: Add enrolled_devices enrichment option to Okta entity analytics provider. + +# REQUIRED for breaking-change, deprecation, known-issue +# Long description; in case the summary is not enough to describe the change +# this field accommodate a description without length limits. +description: | + Adds enrolled_devices as a new optional value for the enrich_with configuration + option in the Okta entity analytics provider. When enabled, each user is enriched + with the list of devices enrolled for that user via the List User Devices Okta API + endpoint. The enrichment is opt-in and excluded from the default configuration to + avoid the extra per-user API call that would increase Okta rate limit consumption. + +# REQUIRED for breaking-change, deprecation, known-issue +# impact: + +# REQUIRED for breaking-change, deprecation, known-issue +# action: + +# REQUIRED for all kinds +# Affected component; usually one of "elastic-agent", "fleet-server", "filebeat", "metricbeat", "auditbeat", "all", etc. +component: filebeat + +# AUTOMATED +# OPTIONAL to manually add other PR URLs +# PR URL: A link the PR that added the changeset. +# If not present is automatically filled by the tooling finding the PR where this changelog fragment has been added. +# NOTE: the tooling supports backports, so it's able to fill the original PR number instead of the backport PR number. +# Please provide it if you are adding a fragment for a different PR. +pr: https://github.com/elastic/beats/pull/49813 + +# AUTOMATED +# OPTIONAL to manually add other issue URLs +# Issue URL; optional; the GitHub issue related to this changeset (either closes or is part of). +# If not present is automatically filled by the tooling with the issue linked to the PR number. +# issue: https://github.com/owner/repo/1234 From 843ddfef4f8418bf0a9be2b9273c3987fe2d88b7 Mon Sep 17 00:00:00 2001 From: chemamartinez Date: Wed, 1 Apr 2026 09:34:31 +0200 Subject: [PATCH 3/3] Replace setting name from enrolled_devices to devices --- docs/reference/filebeat/filebeat-input-entity-analytics.md | 4 ++-- x-pack/filebeat/input/entityanalytics/provider/okta/conf.go | 2 +- x-pack/filebeat/input/entityanalytics/provider/okta/okta.go | 2 +- .../filebeat/input/entityanalytics/provider/okta/okta_test.go | 4 ++-- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/reference/filebeat/filebeat-input-entity-analytics.md b/docs/reference/filebeat/filebeat-input-entity-analytics.md index 96f646a410de..6a26cbcb2839 100644 --- a/docs/reference/filebeat/filebeat-input-entity-analytics.md +++ b/docs/reference/filebeat/filebeat-input-entity-analytics.md @@ -1231,9 +1231,9 @@ The datasets to collect from the API. This can be one of "all", "users" or "devi #### `enrich_with` [_enrich_with] -The metadata to enrich users with. This is an array of values that may contain "groups", "roles", "factors" and "enrolled_devices", or "none". If the array only contains "none", no metadata is collected for users. The default behavior is to collect "groups". +The metadata to enrich users with. This is an array of values that may contain "groups", "roles", "factors" and "devices", or "none". If the array only contains "none", no metadata is collected for users. The default behavior is to collect "groups". -When "enrolled_devices" is included, each user is enriched with the list of devices enrolled for that user by calling the [List User Devices](https://developer.okta.com/docs/api/openapi/okta-management/management/tags/userresources/other/listuserdevices) API. This requires one additional API request per user, so it is disabled by default to avoid hitting Okta rate limits. +When "devices" is included, each user is enriched with the list of devices enrolled for that user by calling the [List User Devices](https://developer.okta.com/docs/api/openapi/okta-management/management/tags/userresources/other/listuserdevices) API. This requires one additional API request per user, so it is disabled by default to avoid hitting Okta rate limits. #### `sync_interval` [_sync_interval_4] diff --git a/x-pack/filebeat/input/entityanalytics/provider/okta/conf.go b/x-pack/filebeat/input/entityanalytics/provider/okta/conf.go index 3a66a0ee4d89..f686a9aa5728 100644 --- a/x-pack/filebeat/input/entityanalytics/provider/okta/conf.go +++ b/x-pack/filebeat/input/entityanalytics/provider/okta/conf.go @@ -62,7 +62,7 @@ type conf struct { Dataset string `config:"dataset"` // EnrichWith specifies the additional data that // will be used to enrich user data. It can include - // "groups", "roles", "factors" and "enrolled_devices". + // "groups", "roles", "factors" and "devices". // If it is a single element with "none", no // enrichment is performed. EnrichWith []string `config:"enrich_with"` diff --git a/x-pack/filebeat/input/entityanalytics/provider/okta/okta.go b/x-pack/filebeat/input/entityanalytics/provider/okta/okta.go index 97e77e2e7e96..477c6c06cab5 100644 --- a/x-pack/filebeat/input/entityanalytics/provider/okta/okta.go +++ b/x-pack/filebeat/input/entityanalytics/provider/okta/okta.go @@ -579,7 +579,7 @@ func (p *oktaInput) addUserMetadata(ctx context.Context, u okta.User, state *sta su.Roles = roles } } - if slices.Contains(p.cfg.EnrichWith, "enrolled_devices") { + if slices.Contains(p.cfg.EnrichWith, "devices") { devices, _, err := okta.GetUserDevices(ctx, p.client, p.cfg.OktaDomain, p.getAuthToken(), u.ID, p.lim, p.logger) if err != nil { p.logger.Warnf("failed to get enrolled devices for user %s: %v", u.ID, err) diff --git a/x-pack/filebeat/input/entityanalytics/provider/okta/okta_test.go b/x-pack/filebeat/input/entityanalytics/provider/okta/okta_test.go index c17131d47971..5246869149cb 100644 --- a/x-pack/filebeat/input/entityanalytics/provider/okta/okta_test.go +++ b/x-pack/filebeat/input/entityanalytics/provider/okta/okta_test.go @@ -39,7 +39,7 @@ func TestOktaDoFetch(t *testing.T) { {dataset: "all", enrichWith: []string{"groups"}, wantUsers: true, wantDevices: true}, {dataset: "users", enrichWith: []string{"groups", "roles", "factors"}, wantUsers: true, wantDevices: false}, {dataset: "devices", enrichWith: []string{"groups"}, wantUsers: false, wantDevices: true}, - {dataset: "users", enrichWith: []string{"groups", "enrolled_devices"}, wantUsers: true, wantDevices: false}, + {dataset: "users", enrichWith: []string{"groups", "devices"}, wantUsers: true, wantDevices: false}, } for _, test := range tests { @@ -112,7 +112,7 @@ func TestOktaDoFetch(t *testing.T) { } } var wantUserDevices []okta.Device - if slices.Contains(test.enrichWith, "enrolled_devices") { + if slices.Contains(test.enrichWith, "devices") { err := json.Unmarshal([]byte(userDevices), &wantUserDevices) if err != nil { t.Fatalf("failed to unmarshal user device data: %v", err)