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..6476fc57c6f --- /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 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 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 2130b979528..4ee73b765ae 100644 --- a/docs/reference/filebeat/filebeat-input-entity-analytics.md +++ b/docs/reference/filebeat/filebeat-input-entity-analytics.md @@ -1236,10 +1236,12 @@ 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", "perms", 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", "perms", "devices", or "none". If the array only contains "none", no metadata is collected for users. The default behavior is to collect "groups". Including "perms" causes role permissions to be fetched for each assigned role and stored under `roles[].permissions` in the published event. Because permissions depend on roles, adding "perms" implicitly enables role enrichment even if "roles" is not listed explicitly. This option requires the `okta.roles.read` OAuth2 scope and results in one additional API call per role per user, so it should be enabled with care on large tenants due to Okta API 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, it is disabled by default to help mitigate Okta rate-limit pressure. + #### `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 f03b7842b1a..ed2d8a74dce 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 "perms". + // "groups", "roles", "factors", "perms", and "devices". // If "perms" is included, role permissions are fetched // for each user role (implying "roles"). The "perms" // option requires the okta.roles.read OAuth2 scope. 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 2d9b4b0cd0a..f74283da1aa 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 @@ -288,6 +288,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 3b5411749c2..102f987fb7f 100644 --- a/x-pack/filebeat/input/entityanalytics/provider/okta/okta.go +++ b/x-pack/filebeat/input/entityanalytics/provider/okta/okta.go @@ -607,6 +607,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 } @@ -798,6 +806,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 5395f47eb34..b4654f38ee1 100644 --- a/x-pack/filebeat/input/entityanalytics/provider/okta/okta_test.go +++ b/x-pack/filebeat/input/entityanalytics/provider/okta/okta_test.go @@ -40,6 +40,7 @@ func TestOktaDoFetch(t *testing.T) { {dataset: "users", enrichWith: []string{"groups", "roles", "factors"}, wantUsers: true, wantDevices: false}, {dataset: "devices", enrichWith: []string{"groups"}, wantUsers: false, wantDevices: true}, {dataset: "users", enrichWith: []string{"perms"}, wantUsers: true, wantDevices: false}, + {dataset: "users", enrichWith: []string{"groups", "devices"}, wantUsers: true, wantDevices: false}, } for _, test := range tests { @@ -63,15 +64,18 @@ func TestOktaDoFetch(t *testing.T) { 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"]}}}}]` permissions = `{"permissions":[{"label":"okta.users.read","created":"2021-02-06T16:17:40.000Z","lastUpdated":"2021-02-06T16:17:40.000Z"},{"label":"okta.apps.read","created":"2021-02-06T16:17:40.000Z","lastUpdated":"2021-02-06T16:17:40.000Z"}]}` + // 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, - "permissions": permissions, + "users": users, + "roles": roles, + "groups": groups, + "devices": devices, + "factors": factors, + "permissions": permissions, + "user_devices": userDevices, } var wantUsers []User @@ -121,7 +125,13 @@ func TestOktaDoFetch(t *testing.T) { } wantPerms = result.Permissions } - + 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) // Set the number of repeats. @@ -139,13 +149,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/iam/roles/{roleId}/permissions", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { setHeaders(w) @@ -262,6 +276,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)) + } if slices.Contains(test.enrichWith, "perms") { for j, role := range g.Roles { want := 0 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"` }