From 06603372d77ce6c464444f89704fcce80379265c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Sibiril?= <81782+aureliensibiril@users.noreply.github.com> Date: Thu, 4 Jun 2026 14:45:01 +0200 Subject: [PATCH] Add Okta users driver and name resolver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The driver lists GET /api/v1/users (limit=200) on the customer's org host and follows the RFC 5988 Link header, pinning pagination to the configured host so a response cannot redirect the crawl off-tenant. User status maps to the three-valued Active flag (SUSPENDED and DEPROVISIONED are inactive); ExternalID is the stable Okta user id. The name resolver reads /api/v1/org and returns ("", nil) on any non-2xx so a read-only token lacking org-settings read does not loop the source-name worker. The org domain is operator-supplied and feeds the URL host, so it is the one SSRF-sensitive input: NormalizeOktaDomain validates and strips it on the write path and IsValidOktaDomain re-checks it at driver construction, on top of the transport's SSRF protection. Signed-off-by: Aurélien Sibiril <81782+aureliensibiril@users.noreply.github.com> --- pkg/accessreview/drivers/name_resolver.go | 53 +++++ pkg/accessreview/drivers/okta.go | 222 ++++++++++++++++++++ pkg/accessreview/drivers/okta_test.go | 79 +++++++ pkg/accessreview/drivers/testdata/okta.yaml | 66 ++++++ pkg/connector/okta.go | 87 ++++++++ pkg/connector/okta_test.go | 87 ++++++++ 6 files changed, 594 insertions(+) create mode 100644 pkg/accessreview/drivers/okta.go create mode 100644 pkg/accessreview/drivers/okta_test.go create mode 100644 pkg/accessreview/drivers/testdata/okta.yaml create mode 100644 pkg/connector/okta.go create mode 100644 pkg/connector/okta_test.go diff --git a/pkg/accessreview/drivers/name_resolver.go b/pkg/accessreview/drivers/name_resolver.go index c81bd0cac..769d65a9f 100644 --- a/pkg/accessreview/drivers/name_resolver.go +++ b/pkg/accessreview/drivers/name_resolver.go @@ -898,6 +898,59 @@ func (r *datadogNameResolver) ResolveInstanceName(_ context.Context) (string, er return r.region, nil } +// oktaNameResolver resolves the Okta org name via GET /api/v1/org on the +// configured org host. A non-2xx is terminal — a read-only API token may +// lack org-settings read, so it returns ("", nil) to keep the generic +// source name rather than make the source-name worker retry forever. +type oktaNameResolver struct { + httpClient *http.Client + domain string +} + +func NewOktaNameResolver(httpClient *http.Client, domain string) NameResolver { + return &oktaNameResolver{httpClient: httpClient, domain: domain} +} + +func (r *oktaNameResolver) ResolveInstanceName(ctx context.Context) (string, error) { + if r.domain == "" { + return "", nil + } + + endpoint := url.URL{Scheme: "https", Host: r.domain, Path: "/api/v1/org"} + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil) + if err != nil { + return "", fmt.Errorf("cannot create okta org request: %w", err) + } + + req.Header.Set("Accept", "application/json") + + httpResp, err := r.httpClient.Do(req) + if err != nil { + return "", fmt.Errorf("cannot execute okta org request: %w", err) + } + + defer func() { _ = httpResp.Body.Close() }() + + if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 { + return "", nil + } + + var resp struct { + CompanyName string `json:"companyName"` + Subdomain string `json:"subdomain"` + } + if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil { + return "", fmt.Errorf("cannot decode okta org response: %w", err) + } + + if resp.CompanyName != "" { + return resp.CompanyName, nil + } + + return resp.Subdomain, nil +} + // asanaNameResolver resolves the Asana workspace name. type asanaNameResolver struct { httpClient *http.Client diff --git a/pkg/accessreview/drivers/okta.go b/pkg/accessreview/drivers/okta.go new file mode 100644 index 000000000..42fd7c74a --- /dev/null +++ b/pkg/accessreview/drivers/okta.go @@ -0,0 +1,222 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package drivers + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "strings" + "time" + + "go.probo.inc/probo/pkg/coredata" + "go.probo.inc/probo/pkg/rfc5988" +) + +// OktaDriver lists the users of a single Okta org. The org is identified by +// its bare domain host (e.g. "acme.okta.com") supplied with the API token — +// Okta has no central API gateway, so every request targets that org's own +// host. The connection's transport attaches the `Authorization: SSWS ` +// header (see connector.APIKeyConnection); the driver only sets Accept. +type OktaDriver struct { + httpClient *http.Client + domain string +} + +var _ Driver = (*OktaDriver)(nil) + +type oktaUser struct { + ID string `json:"id"` + Status string `json:"status"` + Created string `json:"created"` + LastLogin string `json:"lastLogin"` + Profile oktaProfile `json:"profile"` +} + +type oktaProfile struct { + FirstName string `json:"firstName"` + LastName string `json:"lastName"` + DisplayName string `json:"displayName"` + Email string `json:"email"` + Login string `json:"login"` + Title string `json:"title"` +} + +// oktaUsersPageLimit is Okta's maximum page size for GET /api/v1/users. +const oktaUsersPageLimit = "200" + +func NewOktaDriver(httpClient *http.Client, domain string) *OktaDriver { + return &OktaDriver{ + httpClient: httpClient, + domain: domain, + } +} + +func (d *OktaDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error) { + endpoint := url.URL{ + Scheme: "https", + Host: d.domain, + Path: "/api/v1/users", + RawQuery: url.Values{"limit": {oktaUsersPageLimit}}.Encode(), + } + + next := endpoint.String() + + var records []AccountRecord + + for range maxPaginationPages { + users, linkNext, err := d.fetchUsersPage(ctx, next) + if err != nil { + return nil, err + } + + for _, u := range users { + email := u.Profile.Email + if email == "" { + email = u.Profile.Login + } + + if email == "" { + continue + } + + record := AccountRecord{ + Email: email, + FullName: oktaFullName(u.Profile), + JobTitle: u.Profile.Title, + Active: oktaActive(u.Status), + MFAStatus: coredata.MFAStatusUnknown, + AuthMethod: coredata.AccessEntryAuthMethodUnknown, + AccountType: coredata.AccessEntryAccountTypeUser, + ExternalID: u.ID, + } + + if t, ok := parseOktaTimestamp(u.Created); ok { + record.CreatedAt = &t + } + + if t, ok := parseOktaTimestamp(u.LastLogin); ok { + record.LastLogin = &t + } + + records = append(records, record) + } + + if linkNext == "" { + return records, nil + } + + next = linkNext + } + + return nil, fmt.Errorf("cannot list all okta accounts: %w", ErrPaginationLimitReached) +} + +func (d *OktaDriver) fetchUsersPage(ctx context.Context, endpoint string) ([]oktaUser, string, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, "", fmt.Errorf("cannot create okta users request: %w", err) + } + + req.Header.Set("Accept", "application/json") + + httpResp, err := d.httpClient.Do(req) + if err != nil { + return nil, "", fmt.Errorf("cannot execute okta users request: %w", err) + } + + defer func() { + _ = httpResp.Body.Close() + }() + + if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 { + return nil, "", fmt.Errorf("cannot fetch okta users: unexpected status %d", httpResp.StatusCode) + } + + var users []oktaUser + if err := json.NewDecoder(httpResp.Body).Decode(&users); err != nil { + return nil, "", fmt.Errorf("cannot decode okta users response: %w", err) + } + + // Okta emits one Link header per relation (self, next), so Header.Get + // would return only the first. Join all of them before scanning for + // rel="next". + next, err := d.nextPageURL(strings.Join(httpResp.Header.Values("Link"), ", ")) + if err != nil { + return nil, "", err + } + + return users, next, nil +} + +// nextPageURL returns the rel="next" pagination URL from an Okta Link header, +// or "" when the list is exhausted. It pins pagination to the configured org +// host: a `next` link pointing at any other host is rejected rather than +// followed, so the response cannot redirect the crawl off-tenant. +func (d *OktaDriver) nextPageURL(linkHeader string) (string, error) { + raw := rfc5988.FindByRel(linkHeader, "next") + if raw == "" { + return "", nil + } + + u, err := url.Parse(raw) + if err != nil { + return "", fmt.Errorf("cannot parse okta next-page link") + } + + if !strings.EqualFold(u.Hostname(), d.domain) { + return "", fmt.Errorf("cannot follow okta next-page link: host mismatch") + } + + return u.String(), nil +} + +func oktaFullName(p oktaProfile) string { + if p.DisplayName != "" { + return p.DisplayName + } + + return strings.TrimSpace(strings.Join([]string{p.FirstName, p.LastName}, " ")) +} + +// oktaActive maps an Okta user status to the three-valued Active flag. +// SUSPENDED and DEPROVISIONED are the explicitly disabled/deactivated states; +// every other status (ACTIVE, PROVISIONED, STAGED, RECOVERY, PASSWORD_EXPIRED, +// LOCKED_OUT) is a usable account. An empty status leaves Active nil. +func oktaActive(status string) *bool { + if status == "" { + return nil + } + + active := status != "SUSPENDED" && status != "DEPROVISIONED" + + return &active +} + +func parseOktaTimestamp(value string) (time.Time, bool) { + if value == "" { + return time.Time{}, false + } + + for _, layout := range []string{time.RFC3339Nano, time.RFC3339} { + if t, err := time.Parse(layout, value); err == nil { + return t, true + } + } + + return time.Time{}, false +} diff --git a/pkg/accessreview/drivers/okta_test.go b/pkg/accessreview/drivers/okta_test.go new file mode 100644 index 000000000..7da290836 --- /dev/null +++ b/pkg/accessreview/drivers/okta_test.go @@ -0,0 +1,79 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package drivers + +import ( + "context" + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestOktaDriver(t *testing.T) { + t.Parallel() + + rec := newRecorder(t, "testdata/okta", "OKTA_API_TOKEN") + + authValue := "" + if token := os.Getenv("OKTA_API_TOKEN"); token != "" { + authValue = "SSWS " + token + } + + client := newVCRClient(rec, authValue) + + domain := os.Getenv("OKTA_DOMAIN") + if domain == "" { + domain = "acme.okta.com" + } + + driver := NewOktaDriver(client, domain) + records, err := driver.ListAccounts(context.Background()) + require.NoError(t, err) + + // Two pages followed via the Link header; the third page-1 user has no + // email and is dropped, so three records survive. + require.Len(t, records, 3) + + // Alice: active, displayName preferred, title + timestamps populated. + assert.Equal(t, "alice@example.com", records[0].Email) + assert.Equal(t, "Alice Active", records[0].FullName) + assert.Equal(t, "Security Engineer", records[0].JobTitle) + require.NotNil(t, records[0].Active) + assert.True(t, *records[0].Active) + assert.Equal(t, "00u1aaaaaaaaaaaaa0h7", records[0].ExternalID) + require.NotNil(t, records[0].CreatedAt) + require.NotNil(t, records[0].LastLogin) + + // Bob: SUSPENDED → inactive, no displayName (falls back to first+last), + // null lastLogin stays nil. + assert.Equal(t, "bob@example.com", records[1].Email) + assert.Equal(t, "Bob Suspended", records[1].FullName) + assert.Empty(t, records[1].JobTitle) + require.NotNil(t, records[1].Active) + assert.False(t, *records[1].Active) + assert.Equal(t, "00u2bbbbbbbbbbbbb1h7", records[1].ExternalID) + assert.Nil(t, records[1].LastLogin) + require.NotNil(t, records[1].CreatedAt) + + // Carol: page 2, DEPROVISIONED → inactive. + assert.Equal(t, "carol@example.com", records[2].Email) + assert.Equal(t, "Carol Gone", records[2].FullName) + assert.Equal(t, "Contractor", records[2].JobTitle) + require.NotNil(t, records[2].Active) + assert.False(t, *records[2].Active) + assert.Equal(t, "00u4ddddddddddddd4h7", records[2].ExternalID) +} diff --git a/pkg/accessreview/drivers/testdata/okta.yaml b/pkg/accessreview/drivers/testdata/okta.yaml new file mode 100644 index 000000000..2dded5315 --- /dev/null +++ b/pkg/accessreview/drivers/testdata/okta.yaml @@ -0,0 +1,66 @@ +--- +version: 2 +interactions: + - id: 0 + request: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + content_length: 0 + host: acme.okta.com + form: + limit: + - "200" + headers: + Accept: + - application/json + url: https://acme.okta.com/api/v1/users?limit=200 + method: GET + response: + proto: HTTP/2.0 + proto_major: 2 + proto_minor: 0 + content_length: -1 + uncompressed: true + body: '[{"id":"00u1aaaaaaaaaaaaa0h7","status":"ACTIVE","created":"2024-01-15T10:00:00.000Z","lastLogin":"2026-05-20T08:30:00.000Z","profile":{"firstName":"Alice","lastName":"Active","displayName":"Alice Active","email":"alice@example.com","login":"alice@example.com","title":"Security Engineer"}},{"id":"00u2bbbbbbbbbbbbb1h7","status":"SUSPENDED","created":"2024-02-20T11:00:00.000Z","lastLogin":null,"profile":{"firstName":"Bob","lastName":"Suspended","email":"bob@example.com","login":"bob@example.com"}},{"id":"00u3ccccccccccccc2h7","status":"ACTIVE","created":"2024-03-01T12:00:00.000Z","profile":{"firstName":"No","lastName":"Email","email":"","login":""}}]' + headers: + Content-Type: + - application/json + Link: + - ; rel="self" + - ; rel="next" + status: 200 OK + code: 200 + duration: 18ms + - id: 1 + request: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + content_length: 0 + host: acme.okta.com + form: + after: + - 00ucursor0000000003h7 + limit: + - "200" + headers: + Accept: + - application/json + url: https://acme.okta.com/api/v1/users?after=00ucursor0000000003h7&limit=200 + method: GET + response: + proto: HTTP/2.0 + proto_major: 2 + proto_minor: 0 + content_length: -1 + uncompressed: true + body: '[{"id":"00u4ddddddddddddd4h7","status":"DEPROVISIONED","created":"2024-04-10T09:00:00.000Z","lastLogin":"2025-12-01T07:00:00.000Z","profile":{"firstName":"Carol","lastName":"Gone","email":"carol@example.com","login":"carol@example.com","title":"Contractor"}}]' + headers: + Content-Type: + - application/json + Link: + - ; rel="self" + status: 200 OK + code: 200 + duration: 15ms diff --git a/pkg/connector/okta.go b/pkg/connector/okta.go new file mode 100644 index 000000000..bae857c03 --- /dev/null +++ b/pkg/connector/okta.go @@ -0,0 +1,87 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package connector + +import ( + "fmt" + "net" + "net/url" + "regexp" + "strings" +) + +const OktaProvider = "OKTA" + +// oktaDomainRe matches a dotted DNS hostname (at least two labels; each +// label 1-63 chars of [a-z0-9-], not starting or ending with a hyphen). +// Okta supports both *.okta.com / *.oktapreview.com orgs and fully custom +// domains, so the suffix is intentionally unrestricted — the host shape and +// the IP-literal rejection below, plus the transport's SSRF protection, are +// the guards. +var oktaDomainRe = regexp.MustCompile(`^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+$`) + +// NormalizeOktaDomain extracts and validates the bare Okta org host from +// operator input. It accepts either a bare host ("acme.okta.com") or a full +// URL ("https://acme.okta.com/"), strips any scheme/path, lowercases, and +// rejects explicit ports, IP literals, and malformed hostnames. The returned +// host is what the driver and name resolver interpolate into the per-org API +// host (https:///api/v1/...), so it is the single SSRF-sensitive input +// and must be validated here on the write path. +func NormalizeOktaDomain(raw string) (string, error) { + s := strings.TrimSpace(raw) + if s == "" { + return "", fmt.Errorf("cannot normalize okta domain: empty") + } + + // url.Parse needs a scheme to populate Host; add a placeholder for bare + // hosts. The scheme itself is discarded — only the hostname is kept. + if !strings.Contains(s, "://") { + s = "https://" + s + } + + u, err := url.Parse(s) + if err != nil { + return "", fmt.Errorf("cannot normalize okta domain: invalid") + } + + if u.Port() != "" { + return "", fmt.Errorf("cannot normalize okta domain: ports are not allowed") + } + + host := strings.ToLower(u.Hostname()) + if !IsValidOktaDomain(host) { + return "", fmt.Errorf("cannot normalize okta domain: invalid host") + } + + return host, nil +} + +// IsValidOktaDomain reports whether host is a syntactically valid Okta org +// domain (a dotted DNS hostname, not an IP literal). It re-validates the +// stored domain at driver/name-resolver construction time as defense in +// depth, regardless of how the connector row was populated. +func IsValidOktaDomain(host string) bool { + if host == "" || len(host) > 253 { + return false + } + + // Reject IP literals: an Okta org is always a DNS name, and an IP host + // would sidestep the hostname shape check below. + if net.ParseIP(host) != nil { + return false + } + + return oktaDomainRe.MatchString(host) +} diff --git a/pkg/connector/okta_test.go b/pkg/connector/okta_test.go new file mode 100644 index 000000000..4875b4f43 --- /dev/null +++ b/pkg/connector/okta_test.go @@ -0,0 +1,87 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package connector_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "go.probo.inc/probo/pkg/connector" +) + +func TestNormalizeOktaDomain(t *testing.T) { + t.Parallel() + + t.Run("valid inputs normalize to the bare host", func(t *testing.T) { + t.Parallel() + + cases := map[string]string{ + "acme.okta.com": "acme.okta.com", + " acme.okta.com ": "acme.okta.com", + "https://acme.okta.com": "acme.okta.com", + "https://acme.okta.com/": "acme.okta.com", + "http://acme.okta.com/sso/saml": "acme.okta.com", + "ACME.OKTA.COM": "acme.okta.com", + "dev-12345.okta.com": "dev-12345.okta.com", + "login.acme.com": "login.acme.com", + "acme.oktapreview.com": "acme.oktapreview.com", + } + + for input, want := range cases { + got, err := connector.NormalizeOktaDomain(input) + require.NoErrorf(t, err, "input %q", input) + assert.Equalf(t, want, got, "input %q", input) + } + }) + + t.Run("invalid inputs are rejected", func(t *testing.T) { + t.Parallel() + + inputs := []string{ + "", + " ", + "localhost", + "okta", + "acme.okta.com:8080", + "https://acme.okta.com:443", + "127.0.0.1", + "169.254.169.254", + "::1", + "acme .okta.com", + "-acme.okta.com", + } + + for _, input := range inputs { + _, err := connector.NormalizeOktaDomain(input) + assert.Errorf(t, err, "input %q should be rejected", input) + } + }) +} + +func TestIsValidOktaDomain(t *testing.T) { + t.Parallel() + + assert.True(t, connector.IsValidOktaDomain("acme.okta.com")) + assert.True(t, connector.IsValidOktaDomain("dev-12345.okta.com")) + assert.True(t, connector.IsValidOktaDomain("login.acme.co.uk")) + + assert.False(t, connector.IsValidOktaDomain("")) + assert.False(t, connector.IsValidOktaDomain("localhost")) + assert.False(t, connector.IsValidOktaDomain("192.168.0.1")) + assert.False(t, connector.IsValidOktaDomain("acme.okta.com:443")) + assert.False(t, connector.IsValidOktaDomain("ACME.OKTA.COM")) +}