From 3ac792ddc2e6a187d89dad551cbddb9b9e58ff87 Mon Sep 17 00:00:00 2001 From: Dan Kortschak Date: Tue, 16 May 2023 14:01:37 +0930 Subject: [PATCH] x-pack/filebeat/input/entityanalytics/provider/okta/internal/okta: new package --- CHANGELOG-developer.next.asciidoc | 1 + .../provider/okta/internal/okta/okta.go | 302 ++++++++++++++++++ .../provider/okta/internal/okta/okta_test.go | 162 ++++++++++ 3 files changed, 465 insertions(+) create mode 100644 x-pack/filebeat/input/entityanalytics/provider/okta/internal/okta/okta.go create mode 100644 x-pack/filebeat/input/entityanalytics/provider/okta/internal/okta/okta_test.go diff --git a/CHANGELOG-developer.next.asciidoc b/CHANGELOG-developer.next.asciidoc index c014d22322f2..f07f4facb8e3 100644 --- a/CHANGELOG-developer.next.asciidoc +++ b/CHANGELOG-developer.next.asciidoc @@ -153,6 +153,7 @@ The list below covers the major changes between 7.0.0-rc2 and main only. - Add the file path of the instance lock on the error when it's is already locked {pull}33788[33788] - Add DropFields processor to js API {pull}33458[33458] - Add support for different folders when testing data {pull}34467[34467] +- 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 000000000000..91ef52fe9b42 --- /dev/null +++ b/x-pack/filebeat/input/entityanalytics/provider/okta/internal/okta/okta.go @@ -0,0 +1,302 @@ +// 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" + "log" + "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,omitempty"` + 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"` // Contains "value"; omit but mark. + RecoveryQuestion struct{} `json:"recovery_question"` // 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 000000000000..bdbaf079ea4b --- /dev/null +++ b/x-pack/filebeat/input/entityanalytics/provider/okta/internal/okta/okta_test.go @@ -0,0 +1,162 @@ +// 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" + "io" + "net/http" + "net/url" + "os" + "testing" + "time" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "golang.org/x/time/rate" +) + +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 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") + } + }) + }) + } +} + +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) + } + } +}