From 68371a0974990279c9a2d3a19ef7086a0ea66bd6 Mon Sep 17 00:00:00 2001 From: Dan Kortschak Date: Fri, 7 Jun 2024 08:06:24 +0930 Subject: [PATCH] x-pack/filebeat/input/entityanalytics/provider/okta: add user group membership support (#39815) --- CHANGELOG.next.asciidoc | 1 + .../provider/okta/internal/okta/okta.go | 34 +++++++++++++++++-- .../provider/okta/internal/okta/okta_test.go | 25 ++++++++++++++ .../entityanalytics/provider/okta/okta.go | 16 +++++++-- .../provider/okta/okta_test.go | 25 +++++++++++++- .../provider/okta/statestore.go | 3 +- 6 files changed, 98 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.next.asciidoc b/CHANGELOG.next.asciidoc index 746e1e9f13d..93d33bee5c4 100644 --- a/CHANGELOG.next.asciidoc +++ b/CHANGELOG.next.asciidoc @@ -290,6 +290,7 @@ https://github.com/elastic/beats/compare/v8.8.1\...main[Check the HEAD diff] - Make HTTP Endpoint input GA. {issue}38979[38979] {pull}39410[39410] - Update CEL mito extensions to v1.12.2. {pull}39755[39755] - Add support for base64-encoded HMAC headers to HTTP Endpoint. {pull}39655[39655] +- Add user group membership support to Okta entity analytics provider. {issue}39814[39814] {pull}39815[39815] *Auditbeat* 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 aae221e6be9..58495cbcd6c 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 @@ -9,6 +9,7 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -98,6 +99,14 @@ type Provider struct { Name *string `json:"name,omitempty"` } +// Group is an Okta user group. +// +// See https://developer.okta.com/docs/reference/api/users/#request-parameters-8 (no anchor exists on the page for this endpoint) for details. +type Group struct { + ID string `json:"id"` + Profile map[string]any `json:"profile"` +} + // 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 @@ -223,6 +232,27 @@ func GetUserDetails(ctx context.Context, cli *http.Client, host, key, user strin return getDetails[User](ctx, cli, u, key, user == "", omit, lim, window) } +// GetUserGroupDetails returns Okta group details using the users API endpoint. 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/reference/api/users/#request-parameters-8 (no anchor exists on the page for this endpoint) for details. +func GetUserGroupDetails(ctx context.Context, cli *http.Client, host, key, user string, lim *rate.Limiter, window time.Duration) ([]Group, http.Header, error) { + const endpoint = "/api/v1/users" + + if user == "" { + return nil, nil, errors.New("no user specified") + } + + u := &url.URL{ + Scheme: "https", + Host: host, + Path: path.Join(endpoint, user, "groups"), + } + return getDetails[Group](ctx, cli, u, key, true, OmitNone, 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. @@ -242,7 +272,7 @@ func GetDeviceDetails(ctx context.Context, cli *http.Client, host, key, device s return getDetails[Device](ctx, cli, u, key, device == "", OmitNone, lim, window) } -// GetDeviceUsers returns Okta user details for users asscoiated with the provided device identifier +// GetDeviceUsers returns Okta user details for users associated 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. // @@ -276,7 +306,7 @@ func GetDeviceUsers(ctx context.Context, cli *http.Client, host, key, device str // entity is an Okta entity analytics entity. type entity interface { - User | Device | devUser + User | Group | Device | devUser } type devUser struct { 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 63b6dbf6ba2..58816ef0f41 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 @@ -86,6 +86,31 @@ func Test(t *testing.T) { return } + t.Run("my_groups", func(t *testing.T) { + query := make(url.Values) + query.Set("limit", "200") + groups, _, err := GetUserGroupDetails(context.Background(), http.DefaultClient, host, key, me.ID, limiter, window) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(groups) == 0 { + t.Fatalf("unexpected len(groups): got:%d want:1", len(groups)) + } + + if omit&OmitCredentials != 0 && me.Credentials != nil { + t.Errorf("unexpected credentials with %s: %#v", omit, me.Credentials) + } + + if !*logResponses { + return + } + b, err := json.Marshal(groups) + if err != nil { + t.Errorf("failed to marshal groups for logging: %v", err) + } + t.Logf("groups: %s", b) + }) + t.Run("user", func(t *testing.T) { if me.Profile.Login == "" { b, _ := json.Marshal(me) diff --git a/x-pack/filebeat/input/entityanalytics/provider/okta/okta.go b/x-pack/filebeat/input/entityanalytics/provider/okta/okta.go index d56ae757060..70f95d7396e 100644 --- a/x-pack/filebeat/input/entityanalytics/provider/okta/okta.go +++ b/x-pack/filebeat/input/entityanalytics/provider/okta/okta.go @@ -385,7 +385,7 @@ func (p *oktaInput) doFetchUsers(ctx context.Context, state *stateStore, fullSyn if fullSync { for _, u := range batch { - state.storeUser(u) + p.addGroup(ctx, u, state) if u.LastUpdated.After(lastUpdated) { lastUpdated = u.LastUpdated } @@ -393,7 +393,8 @@ func (p *oktaInput) doFetchUsers(ctx context.Context, state *stateStore, fullSyn } else { users = grow(users, len(batch)) for _, u := range batch { - users = append(users, state.storeUser(u)) + su := p.addGroup(ctx, u, state) + users = append(users, su) if u.LastUpdated.After(lastUpdated) { lastUpdated = u.LastUpdated } @@ -424,6 +425,17 @@ func (p *oktaInput) doFetchUsers(ctx context.Context, state *stateStore, fullSyn return users, nil } +func (p *oktaInput) addGroup(ctx context.Context, u okta.User, state *stateStore) *User { + su := state.storeUser(u) + groups, _, err := okta.GetUserGroupDetails(ctx, p.client, p.cfg.OktaDomain, p.cfg.OktaToken, u.ID, p.lim, p.cfg.LimitWindow) + if err != nil { + p.logger.Warnf("failed to get user group membership for %s: %v", u.ID, err) + return su + } + su.Groups = groups + return su +} + // doFetchDevices handles fetching device and associated user identities from Okta. // If fullSync is true, then any existing deltaLink will be ignored, forcing a full // synchronization from Okta. 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 1286cc24689..da29666712b 100644 --- a/x-pack/filebeat/input/entityanalytics/provider/okta/okta_test.go +++ b/x-pack/filebeat/input/entityanalytics/provider/okta/okta_test.go @@ -18,6 +18,7 @@ import ( "golang.org/x/time/rate" + "github.com/elastic/beats/v7/x-pack/filebeat/input/entityanalytics/provider/okta/internal/okta" "github.com/elastic/elastic-agent-libs/logp" ) @@ -49,11 +50,13 @@ func TestOktaDoFetch(t *testing.T) { window = time.Minute key = "token" users = `[{"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"}}}]` + groups = `[{"id":"USERID","profile":{"description":"All users in your organization","name":"Everyone"}}]` 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"]}}}}]` ) data := map[string]string{ "users": users, + "groups": groups, "devices": devices, } @@ -63,6 +66,14 @@ func TestOktaDoFetch(t *testing.T) { if err != nil { t.Fatalf("failed to unmarshal user data: %v", err) } + var wantGroups []okta.Group + err = json.Unmarshal([]byte(groups), &wantGroups) + if err != nil { + t.Fatalf("failed to unmarshal user data: %v", err) + } + for i, u := range wantUsers { + wantUsers[i].Groups = append(u.Groups, wantGroups...) + } } var wantDevices []Device if test.wantDevices { @@ -83,6 +94,12 @@ func TestOktaDoFetch(t *testing.T) { w.Header().Add("x-rate-limit-remaining", "49") w.Header().Add("x-rate-limit-reset", fmt.Sprint(time.Now().Add(time.Minute).Unix())) + if strings.HasPrefix(r.URL.Path, "/api/v1/users") && strings.HasSuffix(r.URL.Path, "groups") { + // Give the groups if this is a get user groups request. + userid := strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/api/v1/users/"), "/groups") + fmt.Fprintln(w, strings.ReplaceAll(data["groups"], "USERID", userid)) + return + } if strings.HasPrefix(r.URL.Path, "/api/v1/device") && strings.HasSuffix(r.URL.Path, "users") { // Give one user if this is a get device users request. fmt.Fprintln(w, data["users"]) @@ -158,9 +175,15 @@ func TestOktaDoFetch(t *testing.T) { t.Errorf("unexpected number of results: got:%d want:%d", len(got), wantCount(repeats, test.wantUsers)) } for i, g := range got { - if wantID := fmt.Sprintf("userid%d", i+1); g.ID != wantID { + wantID := fmt.Sprintf("userid%d", i+1) + if g.ID != wantID { t.Errorf("unexpected user ID for user %d: got:%s want:%s", i, g.ID, wantID) } + 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) + } + } if g.State != wantStates[g.ID] { t.Errorf("unexpected user state for user %s: got:%s want:%s", g.ID, g.State, wantStates[g.ID]) } diff --git a/x-pack/filebeat/input/entityanalytics/provider/okta/statestore.go b/x-pack/filebeat/input/entityanalytics/provider/okta/statestore.go index 8a11376af51..a54fc3b9928 100644 --- a/x-pack/filebeat/input/entityanalytics/provider/okta/statestore.go +++ b/x-pack/filebeat/input/entityanalytics/provider/okta/statestore.go @@ -39,7 +39,8 @@ const ( type User struct { okta.User `json:"properties"` - State State `json:"state"` + Groups []okta.Group `json:"groups"` + State State `json:"state"` } type Device struct {