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 00000000000..a28cc47bfb2 --- /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 diff --git a/docs/reference/filebeat/filebeat-input-entity-analytics.md b/docs/reference/filebeat/filebeat-input-entity-analytics.md index 3878549d84d..6a26cbcb283 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 "devices", or "none". If the array only contains "none", no metadata is collected for users. The default behavior is to collect "groups". + +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 587dde04c8a..f686a9aa572 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 "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 a9245d39104..054b8941be4 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 6114145faa3..477c6c06cab 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, "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 5e9974ee6b8..5246869149c 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", "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, "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 296cbed5a40..801903ba2a9 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"` }