diff --git a/pkg/accessreview/drivers/clerk.go b/pkg/accessreview/drivers/clerk.go new file mode 100644 index 000000000..0238ab34d --- /dev/null +++ b/pkg/accessreview/drivers/clerk.go @@ -0,0 +1,248 @@ +// 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" + "io" + "net/http" + "strings" + "time" + + "go.probo.inc/probo/pkg/coredata" +) + +const ( + clerkUsersEndpoint = "https://api.clerk.com/v1/users" + clerkUsersPageSize = 100 +) + +type ClerkDriver struct { + httpClient *http.Client +} + +var _ Driver = (*ClerkDriver)(nil) + +type clerkUser struct { + ID string `json:"id"` + PrimaryEmailAddressID *string `json:"primary_email_address_id"` + Username *string `json:"username"` + FirstName *string `json:"first_name"` + LastName *string `json:"last_name"` + PasswordEnabled bool `json:"password_enabled"` + TwoFactorEnabled bool `json:"two_factor_enabled"` + TOTPEnabled bool `json:"totp_enabled"` + BackupCodeEnabled bool `json:"backup_code_enabled"` + Banned bool `json:"banned"` + Locked bool `json:"locked"` + LastSignInAt *int64 `json:"last_sign_in_at"` + CreatedAt int64 `json:"created_at"` + EmailAddresses []struct { + ID string `json:"id"` + EmailAddress string `json:"email_address"` + } `json:"email_addresses"` +} + +type clerkUsersEnvelope struct { + Data []clerkUser `json:"data"` +} + +func NewClerkDriver(httpClient *http.Client) *ClerkDriver { + return &ClerkDriver{ + httpClient: &http.Client{ + Transport: &retryRoundTripper{ + next: httpClient.Transport, + maxRetries: 3, + }, + }, + } +} + +func (d *ClerkDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error) { + var ( + records []AccountRecord + offset = 0 + ) + + for range maxPaginationPages { + users, err := d.fetchUsersPage(ctx, offset) + if err != nil { + return nil, err + } + + if len(users) == 0 { + return records, nil + } + + for _, u := range users { + email := clerkPrimaryEmail(u) + if email == "" { + continue + } + + record := AccountRecord{ + Email: email, + FullName: clerkFullName(u, email), + Active: new(!u.Banned && !u.Locked), + IsAdmin: false, + MFAStatus: clerkMFAStatus(u), + AuthMethod: clerkAuthMethod(u), + AccountType: coredata.AccessEntryAccountTypeUser, + ExternalID: u.ID, + } + + if createdAt := clerkUnixMillisToTime(u.CreatedAt); createdAt != nil { + record.CreatedAt = createdAt + } + + if u.LastSignInAt != nil { + record.LastLogin = clerkUnixMillisToTime(*u.LastSignInAt) + } + + records = append(records, record) + } + + offset += len(users) + + if len(users) < clerkUsersPageSize { + return records, nil + } + } + + return nil, fmt.Errorf("cannot list all clerk users: %w", ErrPaginationLimitReached) +} + +func (d *ClerkDriver) fetchUsersPage(ctx context.Context, offset int) ([]clerkUser, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, clerkUsersEndpoint, nil) + if err != nil { + return nil, fmt.Errorf("cannot create clerk users request: %w", err) + } + + q := req.URL.Query() + q.Set("limit", fmt.Sprintf("%d", clerkUsersPageSize)) + q.Set("offset", fmt.Sprintf("%d", offset)) + req.URL.RawQuery = q.Encode() + + req.Header.Set("Accept", "application/json") + + httpResp, err := d.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("cannot execute clerk users request: %w", err) + } + + defer func() { + _ = httpResp.Body.Close() + }() + + if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 { + return nil, fmt.Errorf("cannot fetch clerk users: unexpected status %d", httpResp.StatusCode) + } + + body, err := io.ReadAll(httpResp.Body) + if err != nil { + return nil, fmt.Errorf("cannot read clerk users response: %w", err) + } + + var paged clerkUsersEnvelope + if err := json.Unmarshal(body, &paged); err != nil { + return nil, fmt.Errorf("cannot decode clerk users response: %w", err) + } + + if paged.Data != nil { + return paged.Data, nil + } + + var users []clerkUser + if err := json.Unmarshal(body, &users); err != nil { + return nil, fmt.Errorf("cannot decode clerk users list response: %w", err) + } + + return users, nil +} + +func clerkPrimaryEmail(u clerkUser) string { + if u.PrimaryEmailAddressID != nil && *u.PrimaryEmailAddressID != "" { + for _, email := range u.EmailAddresses { + if email.ID == *u.PrimaryEmailAddressID && email.EmailAddress != "" { + return email.EmailAddress + } + } + } + + for _, email := range u.EmailAddresses { + if email.EmailAddress != "" { + return email.EmailAddress + } + } + + return "" +} + +func clerkFullName(u clerkUser, fallback string) string { + firstName := "" + lastName := "" + username := "" + + if u.FirstName != nil { + firstName = *u.FirstName + } + + if u.LastName != nil { + lastName = *u.LastName + } + + if u.Username != nil { + username = *u.Username + } + + fullName := strings.TrimSpace(firstName + " " + lastName) + if fullName != "" { + return fullName + } + + if username != "" { + return username + } + + return fallback +} + +func clerkMFAStatus(u clerkUser) coredata.MFAStatus { + if u.TwoFactorEnabled || u.TOTPEnabled || u.BackupCodeEnabled { + return coredata.MFAStatusEnabled + } + + return coredata.MFAStatusDisabled +} + +func clerkAuthMethod(u clerkUser) coredata.AccessEntryAuthMethod { + if u.PasswordEnabled { + return coredata.AccessEntryAuthMethodPassword + } + + return coredata.AccessEntryAuthMethodUnknown +} + +func clerkUnixMillisToTime(unixMillis int64) *time.Time { + if unixMillis <= 0 { + return nil + } + + t := time.UnixMilli(unixMillis).UTC() + + return &t +} diff --git a/pkg/accessreview/drivers/clerk_test.go b/pkg/accessreview/drivers/clerk_test.go new file mode 100644 index 000000000..2f4d4550b --- /dev/null +++ b/pkg/accessreview/drivers/clerk_test.go @@ -0,0 +1,96 @@ +// 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" + "io" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.probo.inc/probo/pkg/coredata" +) + +func TestClerkDriver(t *testing.T) { + t.Parallel() + + const responseBody = `{"data":[{"id":"usr_000000000000000000000001","primary_email_address_id":"eml_primary_1","username":null,"first_name":"Jane","last_name":"Doe","password_enabled":true,"two_factor_enabled":false,"totp_enabled":false,"backup_code_enabled":false,"banned":false,"locked":false,"last_sign_in_at":1748471521000,"created_at":1748342000000,"email_addresses":[{"id":"eml_secondary_1","email_address":"jane+alt@example.com"},{"id":"eml_primary_1","email_address":"jane@example.com"}]},{"id":"usr_000000000000000000000002","primary_email_address_id":null,"username":"developer-user","first_name":null,"last_name":null,"password_enabled":false,"two_factor_enabled":true,"totp_enabled":false,"backup_code_enabled":false,"banned":false,"locked":false,"last_sign_in_at":null,"created_at":1748343000000,"email_addresses":[{"id":"eml_2","email_address":"developer@example.com"}]},{"id":"usr_000000000000000000000003","primary_email_address_id":"eml_primary_3","username":null,"first_name":null,"last_name":null,"password_enabled":false,"two_factor_enabled":false,"totp_enabled":false,"backup_code_enabled":false,"banned":true,"locked":false,"last_sign_in_at":null,"created_at":1748344000000,"email_addresses":[{"id":"eml_primary_3","email_address":"blocked@example.com"}]}],"total_count":3}` + + requestCount := 0 + client := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + requestCount++ + require.Equal(t, http.MethodGet, req.Method) + require.Equal(t, "https://api.clerk.com/v1/users?limit=100&offset=0", req.URL.String()) + require.Equal(t, "application/json", req.Header.Get("Accept")) + + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{ + "Content-Type": []string{"application/json"}, + }, + Body: io.NopCloser(strings.NewReader(responseBody)), + }, nil + })} + + driver := NewClerkDriver(client) + records, err := driver.ListAccounts(context.Background()) + require.NoError(t, err) + require.Len(t, records, 3) + require.Equal(t, 1, requestCount) + + first := records[0] + assert.Equal(t, "usr_000000000000000000000001", first.ExternalID) + assert.Equal(t, "jane@example.com", first.Email) + assert.Equal(t, "Jane Doe", first.FullName) + require.NotNil(t, first.Active) + assert.True(t, *first.Active) + assert.Equal(t, coredata.MFAStatusDisabled, first.MFAStatus) + assert.Equal(t, coredata.AccessEntryAuthMethodPassword, first.AuthMethod) + assert.NotNil(t, first.CreatedAt) + assert.NotNil(t, first.LastLogin) + + second := records[1] + assert.Equal(t, "developer-user", second.FullName) + require.NotNil(t, second.Active) + assert.True(t, *second.Active) + assert.Equal(t, coredata.MFAStatusEnabled, second.MFAStatus) + assert.Equal(t, coredata.AccessEntryAuthMethodUnknown, second.AuthMethod) + + third := records[2] + assert.Equal(t, "blocked@example.com", third.FullName) + require.NotNil(t, third.Active) + assert.False(t, *third.Active) + assert.Equal(t, coredata.MFAStatusDisabled, third.MFAStatus) +} + +func TestClerkPrimaryEmail(t *testing.T) { + t.Parallel() + + user := clerkUser{ + PrimaryEmailAddressID: new("eml_primary"), + EmailAddresses: []struct { + ID string `json:"id"` + EmailAddress string `json:"email_address"` + }{ + {ID: "eml_secondary", EmailAddress: "secondary@example.com"}, + {ID: "eml_primary", EmailAddress: "primary@example.com"}, + }, + } + + assert.Equal(t, "primary@example.com", clerkPrimaryEmail(user)) +} diff --git a/pkg/connector/provider/builtin.go b/pkg/connector/provider/builtin.go index bd6a42754..f0c8d9c48 100644 --- a/pkg/connector/provider/builtin.go +++ b/pkg/connector/provider/builtin.go @@ -26,6 +26,7 @@ func NewBuiltinRegistry() *Registry { asanaRegistration(), bitbucketRegistration(), brexRegistration(), + clerkRegistration(), clickupRegistration(), cloudflareRegistration(), cursorRegistration(), diff --git a/pkg/connector/provider/clerk.go b/pkg/connector/provider/clerk.go new file mode 100644 index 000000000..e2d029925 --- /dev/null +++ b/pkg/connector/provider/clerk.go @@ -0,0 +1,38 @@ +// 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 provider + +import ( + "context" + "net/http" + + "go.gearno.de/kit/log" + "go.probo.inc/probo/pkg/accessreview/drivers" + "go.probo.inc/probo/pkg/coredata" +) + +func clerkRegistration() *Registration { + return &Registration{ + Provider: coredata.ConnectorProviderClerk, + DisplayName: "Clerk", + SupportsAPIKey: true, + // Clerk's Backend API is authenticated with a server-side secret key + // via Authorization: Bearer ; there is no third-party OAuth2 + // flow for account-listing access reviews. + NewDriver: func(_ context.Context, c *http.Client, _ *coredata.Connector, _ *log.Logger) (drivers.Driver, error) { + return drivers.NewClerkDriver(c), nil + }, + } +} diff --git a/pkg/connector/provider/clerk_test.go b/pkg/connector/provider/clerk_test.go new file mode 100644 index 000000000..abc6f3a16 --- /dev/null +++ b/pkg/connector/provider/clerk_test.go @@ -0,0 +1,50 @@ +// 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 provider_test + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.gearno.de/kit/httpclient" + "go.probo.inc/probo/pkg/accessreview/drivers" + "go.probo.inc/probo/pkg/connector/provider" + "go.probo.inc/probo/pkg/coredata" +) + +func TestClerkRegistration(t *testing.T) { + t.Parallel() + + r := provider.NewBuiltinRegistry() + reg, ok := r.Get(coredata.ConnectorProviderClerk) + require.True(t, ok, "clerk provider must be registered") + + assert.Equal(t, "Clerk", reg.DisplayName) + assert.True(t, reg.SupportsAPIKey) + assert.Equal(t, "", reg.APIKeyHeader) + assert.False(t, reg.APIKeyBasicAuth) + require.NotNil(t, reg.NewDriver, "clerk NewDriver closure must be wired") + + drv, err := reg.NewDriver( + context.Background(), + httpclient.DefaultClient(httpclient.WithSSRFProtection()), + &coredata.Connector{Provider: coredata.ConnectorProviderClerk}, + nil, + ) + require.NoError(t, err) + assert.IsType(t, &drivers.ClerkDriver{}, drv) +} diff --git a/pkg/coredata/connector_provider.go b/pkg/coredata/connector_provider.go index d365e11d4..a8741a826 100644 --- a/pkg/coredata/connector_provider.go +++ b/pkg/coredata/connector_provider.go @@ -50,6 +50,7 @@ const ( ConnectorProviderAsana ConnectorProvider = "ASANA" ConnectorProviderNetlify ConnectorProvider = "NETLIFY" ConnectorProviderClickUp ConnectorProvider = "CLICKUP" + ConnectorProviderClerk ConnectorProvider = "CLERK" ConnectorProviderVercel ConnectorProvider = "VERCEL" ConnectorProviderMonday ConnectorProvider = "MONDAY" ConnectorProviderMetabase ConnectorProvider = "METABASE" @@ -94,6 +95,7 @@ func ConnectorProviders() []ConnectorProvider { ConnectorProviderAsana, ConnectorProviderNetlify, ConnectorProviderClickUp, + ConnectorProviderClerk, ConnectorProviderVercel, ConnectorProviderMonday, ConnectorProviderMetabase, @@ -134,6 +136,7 @@ func (v ConnectorProvider) IsValid() bool { ConnectorProviderAsana, ConnectorProviderNetlify, ConnectorProviderClickUp, + ConnectorProviderClerk, ConnectorProviderVercel, ConnectorProviderMonday, ConnectorProviderMetabase, diff --git a/pkg/coredata/migrations/20260529T032500Z.sql b/pkg/coredata/migrations/20260529T032500Z.sql new file mode 100644 index 000000000..e85a0edd5 --- /dev/null +++ b/pkg/coredata/migrations/20260529T032500Z.sql @@ -0,0 +1,15 @@ +-- 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. + +ALTER TYPE connector_provider ADD VALUE IF NOT EXISTS 'CLERK'; diff --git a/pkg/server/api/console/v1/graphql/connector.graphql b/pkg/server/api/console/v1/graphql/connector.graphql index 8eeff71ad..7135871ed 100644 --- a/pkg/server/api/console/v1/graphql/connector.graphql +++ b/pkg/server/api/console/v1/graphql/connector.graphql @@ -47,6 +47,7 @@ enum ConnectorProvider @goEnum(value: "go.probo.inc/probo/pkg/coredata.ConnectorProviderNetlify") CLICKUP @goEnum(value: "go.probo.inc/probo/pkg/coredata.ConnectorProviderClickUp") + CLERK @goEnum(value: "go.probo.inc/probo/pkg/coredata.ConnectorProviderClerk") VERCEL @goEnum(value: "go.probo.inc/probo/pkg/coredata.ConnectorProviderVercel") MONDAY @goEnum(value: "go.probo.inc/probo/pkg/coredata.ConnectorProviderMonday") METABASE