diff --git a/CHANGELOG-developer.next.asciidoc b/CHANGELOG-developer.next.asciidoc index ae799e2592a..7b186e2f660 100644 --- a/CHANGELOG-developer.next.asciidoc +++ b/CHANGELOG-developer.next.asciidoc @@ -155,6 +155,7 @@ The list below covers the major changes between 7.0.0-rc2 and main only. - Add DropFields processor to js API {pull}33458[33458] - Add support for different folders when testing data {pull}34467[34467] - Add logging of metric registration in inputmon. {pull}35647[35647] +- Add Okta API package for entity analytics. {pull}35478[35478] ==== 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 new file mode 100644 index 00000000000..ff000520b2c --- /dev/null +++ b/x-pack/filebeat/input/entityanalytics/provider/okta/internal/okta/okta.go @@ -0,0 +1,334 @@ +// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +// or more contributor license agreements. Licensed under the Elastic License; +// you may not use this file except in compliance with the Elastic License. + +// Package okta provides Okta API support. +package okta + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "path" + "strconv" + "strings" + "time" + + "golang.org/x/time/rate" +) + +// ISO8601 is the time format accepted by Okta queries. +const ISO8601 = "2006-01-02T15:04:05.000Z" + +// User is an Okta user's details. +// +// See https://developer.okta.com/docs/reference/api/users/#user-properties for details. +type User struct { + ID string `json:"id"` + Status string `json:"status"` + Created time.Time `json:"created"` + Activated time.Time `json:"activated"` + StatusChanged *time.Time `json:"statusChanged,omitempty"` + LastLogin *time.Time `json:"lastLogin,omitempty"` + LastUpdated time.Time `json:"lastUpdated"` + PasswordChanged *time.Time `json:"passwordChanged,omitempty"` + Type map[string]any `json:"type"` + TransitioningToStatus *string `json:"transitioningToStatus,omitempty"` + Profile Profile `json:"profile"` + Credentials *Credentials `json:"credentials,omitempty"` + Links HAL `json:"_links,omitempty"` // See https://developer.okta.com/docs/reference/api/users/#links-object for details. + 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. +type Profile struct { + Login string `json:"login"` + Email string `json:"email"` + SecondEmail *string `json:"secondEmail,omitempty"` + FirstName *string `json:"firstName,omitempty"` + LastName *string `json:"lastName,omitempty"` + MiddleName *string `json:"middleName,omitempty"` + HonorificPrefix *string `json:"honorificPrefix,omitempty"` + HonorificSuffix *string `json:"honorificSuffix,omitempty"` + Title *string `json:"title,omitempty"` + DisplayName *string `json:"displayName,omitempty"` + NickName *string `json:"nickName,omitempty"` + ProfileUrl *string `json:"profileUrl,omitempty"` + PrimaryPhone *string `json:"primaryPhone,omitempty"` + MobilePhone *string `json:"mobilePhone,omitempty"` + StreetAddress *string `json:"streetAddress,omitempty"` + City *string `json:"city,omitempty"` + State *string `json:"state,omitempty"` + ZipCode *string `json:"zipCode,omitempty"` + CountryCode *string `json:"countryCode,omitempty"` + PostalAddress *string `json:"postalAddress,omitempty"` + PreferredLanguage *string `json:"preferredLanguage,omitempty"` + Locale *string `json:"locale,omitempty"` + Timezone *string `json:"timezone,omitempty"` + UserType *string `json:"userType,omitempty"` + EmployeeNumber *string `json:"employeeNumber,omitempty"` + CostCenter *string `json:"costCenter,omitempty"` + Organization *string `json:"organization,omitempty"` + Division *string `json:"division,omitempty"` + Department *string `json:"department,omitempty"` + ManagerId *string `json:"managerId,omitempty"` + Manager *string `json:"manager,omitempty"` +} + +// Credentials is a redacted Okta user's credential details. Only the credential provider is retained. +// +// See https://developer.okta.com/docs/reference/api/users/#credentials-object for details. +type Credentials struct { + Password *struct{} `json:"password,omitempty"` // Contains "value"; omit but mark. + RecoveryQuestion *struct{} `json:"recovery_question,omitempty"` // Contains "question" and "answer"; omit but mark. + Provider Provider `json:"provider"` +} + +// Provider is an Okta credential provider. +// +// See https://developer.okta.com/docs/reference/api/users/#provider-object for details. +type Provider struct { + Type string `json:"type"` + Name *string `json:"name,omitempty"` +} + +// 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. +type Response uint8 + +const ( + // Omit the credentials sub-object from the response. + OmitCredentials Response = 1 << iota + + // Omit the following HAL links from the response: + // Change Password, Change Recovery Question, Forgot Password, Reset Password, Reset Factors, Unlock. + OmitCredentialsLinks + + // Omit the transitioningToStatus field from the response. + OmitTransitioningToStatus + + OmitNone Response = 0 +) + +var oktaResponse = [...]string{ + "omitCredentials", + "omitCredentialsLinks", + "omitTransitioningToStatus", +} + +func (o Response) String() string { + if o == OmitNone { + return "" + } + var buf strings.Builder + buf.WriteString("okta-response=") + var n int + for i, s := range &oktaResponse { + if o&(1<' })) + if err != nil { + return nil, err + } + return u.Query(), nil + } + } + } + return nil, io.EOF +} 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 new file mode 100644 index 00000000000..0ff02bc456e --- /dev/null +++ b/x-pack/filebeat/input/entityanalytics/provider/okta/internal/okta/okta_test.go @@ -0,0 +1,281 @@ +// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +// or more contributor license agreements. Licensed under the Elastic License; +// you may not use this file except in compliance with the Elastic License. + +// Package okta provide Okta user API support. +package okta + +import ( + "context" + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "net/http" + "net/http/httptest" + "net/url" + "os" + "testing" + "time" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "golang.org/x/time/rate" +) + +var logUsers = flag.Bool("log_user_response", false, "use to allow log users returned from the API") + +func Test(t *testing.T) { + // https://developer.okta.com/docs/reference/core-okta-api/ + host, ok := os.LookupEnv("OKTA_HOST") + if !ok { + t.Skip("okta tests require ${OKTA_HOST} to be set") + } + // https://help.okta.com/en-us/Content/Topics/Security/API.htm?cshid=Security_API#Security_API + key, ok := os.LookupEnv("OKTA_TOKEN") + if !ok { + t.Skip("okta tests require ${OKTA_TOKEN} to be set") + } + + // Make a global limiter with the capacity to proceed once. + limiter := rate.NewLimiter(1, 1) + + // 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 + + for _, omit := range []Response{ + OmitNone, + OmitCredentials, + } { + name := "none" + if omit != OmitNone { + name = omit.String() + } + t.Run(name, func(t *testing.T) { + var me User + t.Run("me", func(t *testing.T) { + query := make(url.Values) + query.Set("limit", "200") + users, _, err := GetUserDetails(context.Background(), http.DefaultClient, host, key, "me", query, omit, limiter, window) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(users) != 1 { + t.Fatalf("unexpected len(users): got:%d want:1", len(users)) + } + me = users[0] + + if omit&OmitCredentials != 0 && me.Credentials != nil { + t.Errorf("unexpected credentials with %s: %#v", omit, me.Credentials) + } + + if !*logUsers { + return + } + b, err := json.Marshal(me) + if err != nil { + t.Errorf("failed to marshal user for logging: %v", err) + } + t.Logf("user: %s", b) + }) + if t.Failed() { + return + } + + t.Run("user", func(t *testing.T) { + if me.Profile.Login == "" { + b, _ := json.Marshal(me) + t.Skipf("cannot run user test without profile.login field set: %s", b) + } + + query := make(url.Values) + query.Set("limit", "200") + users, _, err := GetUserDetails(context.Background(), http.DefaultClient, host, key, me.Profile.Login, query, omit, limiter, window) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(users) != 1 { + t.Fatalf("unexpected len(users): got:%d want:1", len(users)) + } + if !cmp.Equal(me, users[0]) { + t.Errorf("unexpected result:\n-'me'\n+'%s'\n%s", me.Profile.Login, cmp.Diff(me, users[0])) + } + }) + + t.Run("all", func(t *testing.T) { + query := make(url.Values) + query.Set("limit", "200") + users, _, err := GetUserDetails(context.Background(), http.DefaultClient, host, key, "", query, omit, limiter, window) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + found := false + for _, u := range users { + if cmp.Equal(me, u, cmpopts.IgnoreFields(User{}, "Links")) { + found = true + } + } + if !found { + t.Error("failed to find 'me' in user list") + } + + if !*logUsers { + return + } + b, err := json.Marshal(users) + if err != nil { + t.Errorf("failed to marshal users for logging: %v", err) + } + t.Logf("users: %s", b) + }) + + t.Run("error", func(t *testing.T) { + query := make(url.Values) + query.Set("limit", "200") + query.Add("search", `not (status pr)`) // This cannot ever be true. + _, _, err := GetUserDetails(context.Background(), http.DefaultClient, host, key, "", query, omit, limiter, window) + oktaErr := &Error{} + if !errors.As(err, &oktaErr) { + // Don't test the value of the error since it was + // determined by observation rather than documentation. + // But log below. + t.Fatalf("expected Okta API error got: %#v", err) + } + t.Logf("actual error: %v", err) + }) + }) + } +} + +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) + if err != nil { + t.Errorf("unexpected error parsing request URI: %v", err) + } + if u.Path != "/api/v1/users" { + t.Errorf("unexpected API endpoint: got:%s want:%s", u.Path, "/api/v1/users") + } + 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) + } + + // 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())) + + // Set next link. + w.Header().Add("link", `; rel="next"`) + + 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 + + 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) + } + + if !cmp.Equal(wantUsers, users) { + t.Errorf("unexpected result:\n- want\n+ got\n%s", cmp.Diff(wantUsers, users)) + } + + 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") + } +} + +var nextTests = []struct { + header http.Header + want string + wantErr error +}{ + 0: { + header: http.Header{"Link": []string{ + `; rel="self"`, + `; rel="next"`, + }}, + want: "after=1627500044869_1&limit=20", + wantErr: nil, + }, + 1: { + header: http.Header{"Link": []string{ + `;rel="self"`, + `;rel="next"`, + }}, + want: "after=1627500044869_1&limit=20", + wantErr: nil, + }, + 2: { + header: http.Header{"Link": []string{ + `; rel = "self"`, + `; rel = "next"`, + }}, + want: "after=1627500044869_1&limit=20", + wantErr: nil, + }, + 3: { + header: http.Header{"Link": []string{ + `; rel="self"`, + }}, + want: "", + wantErr: io.EOF, + }, +} + +func TestNext(t *testing.T) { + for i, test := range nextTests { + got, err := Next(test.header) + if err != test.wantErr { + t.Errorf("unexpected ok result for %d: got:%v want:%v", i, err, test.wantErr) + } + if got.Encode() != test.want { + t.Errorf("unexpected query result for %d: got:%q want:%q", i, got.Encode(), test.want) + } + } +}