diff --git a/CHANGELOG-developer.next.asciidoc b/CHANGELOG-developer.next.asciidoc index d7323a2081b..9afe4ff5857 100644 --- a/CHANGELOG-developer.next.asciidoc +++ b/CHANGELOG-developer.next.asciidoc @@ -165,6 +165,7 @@ The list below covers the major changes between 7.0.0-rc2 and main only. - Add AUTH (username) and SSL/TLS support for Redis module {pull}35240[35240] - Pin PyYAML version to 5.3.1 to avoid CI errors temporarily {pull}36091[36091] - Skip dependabot updates for github.com/elastic/mito. {pull}36158[36158] +- Add device handling to Okta API package for entity analytics. {pull}35980[35980] ==== Deprecated 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 ff000520b2c..30f5e0d4166 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 @@ -44,11 +44,6 @@ type User struct { Embedded HAL `json:"_embedded,omitempty"` } -// HAL is a JSON Hypertext Application Language object. -// -// See https://datatracker.ietf.org/doc/html/draft-kelly-json-hal-06 for details. -type HAL map[string]any - // Profile is an Okta user's profile. // // See https://developer.okta.com/docs/reference/api/users/#profile-object for details. @@ -103,6 +98,61 @@ type Provider struct { Name *string `json:"name,omitempty"` } +// Device is an Okta device's details. +// +// See https://developer.okta.com/docs/api/openapi/okta-management/management/tag/Device/#tag/Device/operation/listDevices for details +type Device struct { + Created time.Time `json:"created"` + ID string `json:"id"` + LastUpdated time.Time `json:"lastUpdated"` + Profile DeviceProfile `json:"profile"` + ResourceAlternateID string `json:"resourceAlternateID"` + ResourceDisplayName DeviceDisplayName `json:"resourceDisplayName"` + ResourceID string `json:"resourceID"` + ResourceType string `json:"resourceType"` + Status string `json:"status"` + Links HAL `json:"_links,omitempty"` // See https://developer.okta.com/docs/reference/api/users/#links-object for details. + + // Users is the set of users associated with the device. + // It is not part of the list devices API return, but can + // be populated by a call to GetDeviceUsers. + Users []User `json:"users,omitempty"` +} + +// DeviceProfile is an Okta device's hardware and security profile. +// +// See https://developer.okta.com/docs/api/openapi/okta-management/management/tag/Device/#tag/Device/operation/listDevices for details +type DeviceProfile struct { + DiskEncryptionType *string `json:"diskEncryptionType,omitempty"` + DisplayName string `json:"displayName"` + IMEI *string `json:"imei,omitempty"` + IntegrityJailBreak *bool `json:"integrityJailBreak,omitempty"` + Manufacturer *string `json:"manufacturer,omitempty"` + MEID *string `json:"meid,omitempty"` + Model *string `json:"model,omitempty"` + OSVersion *string `json:"osVersion,omitempty"` + Platform string `json:"platform"` + Registered bool `json:"registered"` + SecureHardwarePresent *bool `json:"secureHardwarePresent,omitempty"` + SerialNumber *string `json:"serialNumber,omitempty"` + SID *string `json:"sid,omitempty"` + TPMPublicKeyHash *string `json:"tpmPublicKeyHash,omitempty"` + UDID *string `json:"udid,omitempty"` +} + +// DeviceDisplayName is an Okta device's annotated display name. +// +// See https://developer.okta.com/docs/api/openapi/okta-management/management/tag/Device/#tag/Device/operation/listDevices for details +type DeviceDisplayName struct { + Sensitive bool `json:"sensitive"` + Value string `json:"value"` +} + +// HAL is a JSON Hypertext Application Language object. +// +// See https://datatracker.ietf.org/doc/html/draft-kelly-json-hal-06 for details. +type HAL map[string]any + // Response is a set of omit options specifying a part of the response to omit. // // See https://developer.okta.com/docs/reference/api/users/#content-type-header-fields-2 for details. @@ -164,12 +214,81 @@ func (o Response) String() string { func GetUserDetails(ctx context.Context, cli *http.Client, host, key, user string, query url.Values, omit Response, lim *rate.Limiter, window time.Duration) ([]User, http.Header, error) { const endpoint = "/api/v1/users" - u := url.URL{ + u := &url.URL{ Scheme: "https", Host: host, Path: path.Join(endpoint, user), RawQuery: query.Encode(), } + return getDetails[User](ctx, cli, u, key, user == "", omit, lim, window) +} + +// GetDeviceDetails returns Okta device details using the list devices API endpoint. host is the +// Okta user domain and key is the API token to use for the query. If device is not empty, +// details for the specific device are returned, otherwise a list of all devices is returned. +// +// See GetUserDetails for details of the query and rate limit parameters. +// +// See https://developer.okta.com/docs/api/openapi/okta-management/management/tag/Device/#tag/Device/operation/listDevices for details. +func GetDeviceDetails(ctx context.Context, cli *http.Client, host, key, device string, query url.Values, lim *rate.Limiter, window time.Duration) ([]Device, http.Header, error) { + const endpoint = "/api/v1/devices" + + u := &url.URL{ + Scheme: "https", + Host: host, + Path: path.Join(endpoint, device), + RawQuery: query.Encode(), + } + return getDetails[Device](ctx, cli, u, key, device == "", OmitNone, lim, window) +} + +// GetDeviceUsers returns Okta user details for users asscoiated with the provided device identifier +// using the list device users API. host is the Okta user domain and key is the API token to use for +// the query. If device is empty, a nil User slice and header is returned, without error. +// +// See GetUserDetails for details of the query and rate limit parameters. +// +// See https://developer.okta.com/docs/api/openapi/okta-management/management/tag/Device/#tag/Device/operation/listDeviceUsers for details. +func GetDeviceUsers(ctx context.Context, cli *http.Client, host, key, device string, query url.Values, omit Response, lim *rate.Limiter, window time.Duration) ([]User, http.Header, error) { + if device == "" { + // No user associated with a null device. Not an error. + return nil, nil, nil + } + + const endpoint = "/api/v1/devices" + + u := &url.URL{ + Scheme: "https", + Host: host, + Path: path.Join(endpoint, device, "users"), + RawQuery: query.Encode(), + } + du, h, err := getDetails[devUser](ctx, cli, u, key, true, omit, lim, window) + if err != nil { + return nil, h, err + } + users := make([]User, len(du)) + for i, du := range du { + users[i] = du.User + } + return users, h, nil +} + +// entity is an Okta entity analytics entity. +type entity interface { + User | Device | devUser +} + +type devUser struct { + User `json:"user"` +} + +// getDetails returns Okta details using the API endpoint in u. host is the Okta +// user domain and key is the API token to use for the query. If all is false, details +// for the specific user are returned, otherwise a list of all users is returned. +// +// See GetUserDetails for details of the query and rate limit parameters. +func getDetails[E entity](ctx context.Context, cli *http.Client, u *url.URL, key string, all bool, omit Response, lim *rate.Limiter, window time.Duration) ([]E, http.Header, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) if err != nil { return nil, nil, err @@ -203,22 +322,22 @@ func GetUserDetails(ctx context.Context, cli *http.Client, host, key, user strin return nil, nil, err } - if user == "" { - // List all users. - var users []User - err = json.Unmarshal(body.Bytes(), &users) + if all { + // List all entities. + var e []E + err = json.Unmarshal(body.Bytes(), &e) if err != nil { err = recoverError(body.Bytes()) } - return users, resp.Header, err + return e, resp.Header, err } - // Get single user's details. - var users [1]User - err = json.Unmarshal(body.Bytes(), &users[0]) + // Get single entity's details. + var e [1]E + err = json.Unmarshal(body.Bytes(), &e[0]) if err != nil { err = recoverError(body.Bytes()) } - return users[:], resp.Header, err + return e[:], resp.Header, err } // recoverError returns an error based on the returned Okta API error. Error diff --git a/x-pack/filebeat/input/entityanalytics/provider/okta/internal/okta/okta_test.go b/x-pack/filebeat/input/entityanalytics/provider/okta/internal/okta/okta_test.go index 0ff02bc456e..63b6dbf6ba2 100644 --- a/x-pack/filebeat/input/entityanalytics/provider/okta/internal/okta/okta_test.go +++ b/x-pack/filebeat/input/entityanalytics/provider/okta/internal/okta/okta_test.go @@ -16,6 +16,7 @@ import ( "net/http/httptest" "net/url" "os" + "strings" "testing" "time" @@ -24,7 +25,7 @@ import ( "golang.org/x/time/rate" ) -var logUsers = flag.Bool("log_user_response", false, "use to allow log users returned from the API") +var logResponses = flag.Bool("log_response", false, "use to log users/devices returned from the API") func Test(t *testing.T) { // https://developer.okta.com/docs/reference/core-okta-api/ @@ -72,7 +73,7 @@ func Test(t *testing.T) { t.Errorf("unexpected credentials with %s: %#v", omit, me.Credentials) } - if !*logUsers { + if !*logResponses { return } b, err := json.Marshal(me) @@ -122,7 +123,7 @@ func Test(t *testing.T) { t.Error("failed to find 'me' in user list") } - if !*logUsers { + if !*logResponses { return } b, err := json.Marshal(users) @@ -148,85 +149,166 @@ func Test(t *testing.T) { }) }) } -} - -func TestLocal(t *testing.T) { - // Make a global limiter with more capacity than will be set by the mock API. - // This will show the burst drop. - limiter := rate.NewLimiter(10, 10) - - // There are a variety of windows, the most conservative is one minute. - // The rate limit will be adjusted on the second call to the API if - // window is actually used to rate limit calculations. - const window = time.Minute - - const ( - key = "token" - msg = `[{"id":"userid","status":"STATUS","created":"2023-05-14T13:37:20.000Z","activated":null,"statusChanged":"2023-05-15T01:50:30.000Z","lastLogin":"2023-05-15T01:59:20.000Z","lastUpdated":"2023-05-15T01:50:32.000Z","passwordChanged":"2023-05-15T01:50:32.000Z","type":{"id":"typeid"},"profile":{"firstName":"name","lastName":"surname","mobilePhone":null,"secondEmail":null,"login":"name.surname@example.com","email":"name.surname@example.com"},"credentials":{"password":{"value":"secret"},"emails":[{"value":"name.surname@example.com","status":"VERIFIED","type":"PRIMARY"}],"provider":{"type":"OKTA","name":"OKTA"}},"_links":{"self":{"href":"https://localhost/api/v1/users/userid"}}}]` - ) - var wantUsers []User - err := json.Unmarshal([]byte(msg), &wantUsers) - if err != nil { - t.Fatalf("failed to unmarshal user data: %v", err) - } - ts := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - u, err := url.Parse(r.RequestURI) + t.Run("device", func(t *testing.T) { + query := make(url.Values) + query.Set("limit", "200") + devices, _, err := GetDeviceDetails(context.Background(), http.DefaultClient, host, key, "", query, limiter, window) if err != nil { - t.Errorf("unexpected error parsing request URI: %v", err) + t.Fatalf("unexpected error: %v", err) } - if u.Path != "/api/v1/users" { - t.Errorf("unexpected API endpoint: got:%s want:%s", u.Path, "/api/v1/users") + if *logResponses { + b, err := json.Marshal(devices) + if err != nil { + t.Errorf("failed to marshal devices for logging: %v", err) + } + t.Logf("devices: %s", b) } - if got := r.Header.Get("accept"); got != "application/json" { - t.Errorf("unexpected Accept header: got:%s want:%s", got, "application/json") + for _, d := range devices { + users, _, err := GetDeviceUsers(context.Background(), http.DefaultClient, host, key, d.ID, query, OmitCredentials, limiter, window) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + b, err := json.Marshal(users) + if err != nil { + t.Errorf("failed to marshal users for logging: %v", err) + } + t.Logf("users: %s", b) } - if got := r.Header.Get("authorization"); got != "SSWS "+key { - t.Errorf("unexpected Authorization header: got:%s want:%s", got, "SSWS "+key) + }) +} + +var localTests = []struct { + name string + msg string + id string + fn func(ctx context.Context, cli *http.Client, host, key, user string, query url.Values, lim *rate.Limiter, window time.Duration) (any, http.Header, error) + mkWant func(string) (any, error) +}{ + { + // Test case constructed from API-returned value with details anonymised. + name: "users", + msg: `[{"id":"userid","status":"STATUS","created":"2023-05-14T13:37:20.000Z","activated":null,"statusChanged":"2023-05-15T01:50:30.000Z","lastLogin":"2023-05-15T01:59:20.000Z","lastUpdated":"2023-05-15T01:50:32.000Z","passwordChanged":"2023-05-15T01:50:32.000Z","type":{"id":"typeid"},"profile":{"firstName":"name","lastName":"surname","mobilePhone":null,"secondEmail":null,"login":"name.surname@example.com","email":"name.surname@example.com"},"credentials":{"password":{"value":"secret"},"emails":[{"value":"name.surname@example.com","status":"VERIFIED","type":"PRIMARY"}],"provider":{"type":"OKTA","name":"OKTA"}},"_links":{"self":{"href":"https://localhost/api/v1/users/userid"}}}]`, + fn: func(ctx context.Context, cli *http.Client, host, key, user string, query url.Values, lim *rate.Limiter, window time.Duration) (any, http.Header, error) { + return GetUserDetails(context.Background(), cli, host, key, user, query, OmitNone, lim, window) + }, + mkWant: mkWant[User], + }, + { + // Test case from https://developer.okta.com/docs/api/openapi/okta-management/management/tag/Device/#tag/Device/operation/listDevices + name: "devices", + msg: `[{"id":"devid","status":"CREATED","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":"guo4a5u7YAHhjXrMK0g4","_links":{"activate":{"href":"https://{yourOktaDomain}/api/v1/devices/guo4a5u7YAHhjXrMK0g4/lifecycle/activate","hints":{"allow":["POST"]}},"self":{"href":"https://{yourOktaDomain}/api/v1/devices/guo4a5u7YAHhjXrMK0g4","hints":{"allow":["GET","PATCH","PUT"]}},"users":{"href":"https://{yourOktaDomain}/api/v1/devices/guo4a5u7YAHhjXrMK0g4/users","hints":{"allow":["GET"]}}}},{"id":"guo4a5u7YAHhjXrMK0g5","status":"ACTIVE","created":"2023-06-21T23:24:02.000Z","lastUpdated":"2023-06-21T23:24:02.000Z","profile":{"displayName":"Example Device name 2","platform":"ANDROID","manufacturer":"Google","model":"Pixel 6","osVersion":"13:2023-05-05","registered":true,"secureHardwarePresent":true,"diskEncryptionType":"USER"},"resourceType":"UDDevice","resourceDisplayName":{"value":"Example Device name 2","sensitive":false},"resourceAlternateId":null,"resourceId":"guo4a5u7YAHhjXrMK0g5","_links":{"activate":{"href":"https://{yourOktaDomain}/api/v1/devices/guo4a5u7YAHhjXrMK0g5/lifecycle/activate","hints":{"allow":["POST"]}},"self":{"href":"https://{yourOktaDomain}/api/v1/devices/guo4a5u7YAHhjXrMK0g5","hints":{"allow":["GET","PATCH","PUT"]}},"users":{"href":"https://{yourOktaDomain}/api/v1/devices/guo4a5u7YAHhjXrMK0g5/users","hints":{"allow":["GET"]}}}}]`, + fn: func(ctx context.Context, cli *http.Client, host, key, device string, query url.Values, lim *rate.Limiter, window time.Duration) (any, http.Header, error) { + return GetDeviceDetails(context.Background(), cli, host, key, device, query, lim, window) + }, + mkWant: mkWant[Device], + }, + { + // Test case constructed from API-returned value with details anonymised. + name: "devices_users", + msg: `[{"created":"2023-08-07T21:48:27.000Z","managementStatus":"NOT_MANAGED","user":{"id":"userid","status":"STATUS","created":"2023-05-14T13:37:20.000Z","activated":null,"statusChanged":"2023-05-15T01:50:30.000Z","lastLogin":"2023-05-15T01:59:20.000Z","lastUpdated":"2023-05-15T01:50:32.000Z","passwordChanged":"2023-05-15T01:50:32.000Z","type":{"id":"typeid"},"profile":{"firstName":"name","lastName":"surname","mobilePhone":null,"secondEmail":null,"login":"name.surname@example.com","email":"name.surname@example.com"},"credentials":{"password":{"value":"secret"},"emails":[{"value":"name.surname@example.com","status":"VERIFIED","type":"PRIMARY"}],"provider":{"type":"OKTA","name":"OKTA"}},"_links":{"self":{"href":"https://localhost/api/v1/users/userid"}}}}]`, + id: "devid", + fn: func(ctx context.Context, cli *http.Client, host, key, device string, query url.Values, lim *rate.Limiter, window time.Duration) (any, http.Header, error) { + return GetDeviceUsers(context.Background(), cli, host, key, device, query, OmitNone, lim, window) + }, + mkWant: mkWant[devUser], + }, +} + +func mkWant[E entity](data string) (any, error) { + var v []E + err := json.Unmarshal([]byte(data), &v) + if v, ok := any(v).([]devUser); ok { + users := make([]User, len(v)) + for i, u := range v { + users[i] = u.User } + return users, nil + } + return v, err +} + +func TestLocal(t *testing.T) { + for _, test := range localTests { + t.Run(test.name, func(t *testing.T) { + // Make a global limiter with more capacity than will be set by the mock API. + // This will show the burst drop. + limiter := rate.NewLimiter(10, 10) - // Leave 49 remaining, reset in one minute. - w.Header().Add("x-rate-limit-limit", "50") - w.Header().Add("x-rate-limit-remaining", "49") - w.Header().Add("x-rate-limit-reset", fmt.Sprint(time.Now().Add(time.Minute).Unix())) + // There are a variety of windows, the most conservative is one minute. + // The rate limit will be adjusted on the second call to the API if + // window is actually used to rate limit calculations. + const window = time.Minute - // Set next link. - w.Header().Add("link", `; rel="next"`) + const key = "token" + want, err := test.mkWant(test.msg) + if err != nil { + t.Fatalf("failed to unmarshal entity data: %v", err) + } - fmt.Fprintln(w, msg) - })) - defer ts.Close() - u, err := url.Parse(ts.URL) - if err != nil { - t.Errorf("failed to parse server URL: %v", err) - } - host := u.Host + ts := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + u, err := url.Parse(r.RequestURI) + if err != nil { + t.Errorf("unexpected error parsing request URI: %v", err) + } + name, _, ok := strings.Cut(test.name, "_") + endpoint := "/api/v1/" + name + if ok { + endpoint += "/" + test.id + "/users" + } + if u.Path != endpoint { + t.Errorf("unexpected API endpoint: got:%s want:%s", u.Path, endpoint) + } + if got := r.Header.Get("accept"); got != "application/json" { + t.Errorf("unexpected Accept header: got:%s want:%s", got, "application/json") + } + if got := r.Header.Get("authorization"); got != "SSWS "+key { + t.Errorf("unexpected Authorization header: got:%s want:%s", got, "SSWS "+key) + } - query := make(url.Values) - query.Set("limit", "200") - users, h, err := GetUserDetails(context.Background(), ts.Client(), host, key, "", query, OmitNone, limiter, window) - if err != nil { - t.Fatalf("unexpected error from GetUserDetails: %v", err) - } + // Leave 49 remaining, reset in one minute. + w.Header().Add("x-rate-limit-limit", "50") + w.Header().Add("x-rate-limit-remaining", "49") + w.Header().Add("x-rate-limit-reset", fmt.Sprint(time.Now().Add(time.Minute).Unix())) - if !cmp.Equal(wantUsers, users) { - t.Errorf("unexpected result:\n- want\n+ got\n%s", cmp.Diff(wantUsers, users)) - } + // Set next link. + w.Header().Add("link", fmt.Sprintf(`; rel="next"`, test.name)) + fmt.Fprintln(w, test.msg) + })) + defer ts.Close() + u, err := url.Parse(ts.URL) + if err != nil { + t.Errorf("failed to parse server URL: %v", err) + } + host := u.Host - lim := limiter.Limit() - if lim < 49.0/60.0 || 50.0/60.0 < lim { - t.Errorf("unexpected rate limit (outside [49/60, 50/60]: %f", lim) - } - if limiter.Burst() != 1 { // Set in GetUserDetails. - t.Errorf("unexpected burst: got:%d want:1", limiter.Burst()) - } + query := make(url.Values) + query.Set("limit", "200") + got, h, err := test.fn(context.Background(), ts.Client(), host, key, test.id, query, limiter, window) + if err != nil { + t.Fatalf("unexpected error from Get_Details: %v", err) + } - next, err := Next(h) - if err != nil { - t.Errorf("unexpected error from Next: %v", err) - } - if query := next.Encode(); query != "after=opaquevalue&limit=200" { - t.Errorf("unexpected next query: got:%s want:%s", query, "after=opaquevalue&limit=200") + if !cmp.Equal(want, got) { + t.Errorf("unexpected result:\n- want\n+ got\n%s", cmp.Diff(want, got)) + } + + lim := limiter.Limit() + if lim < 49.0/60.0 || 50.0/60.0 < lim { + t.Errorf("unexpected rate limit (outside [49/60, 50/60]: %f", lim) + } + if limiter.Burst() != 1 { // Set in GetUserDetails. + t.Errorf("unexpected burst: got:%d want:1", limiter.Burst()) + } + + next, err := Next(h) + if err != nil { + t.Errorf("unexpected error from Next: %v", err) + } + if query := next.Encode(); query != "after=opaquevalue&limit=200" { + t.Errorf("unexpected next query: got:%s want:%s", query, "after=opaquevalue&limit=200") + } + }) } }