From c77d06b42b3bd5cdbefbae7e1913c539948a22ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Sibiril?= <81782+aureliensibiril@users.noreply.github.com> Date: Sat, 11 Jul 2026 23:15:03 +0200 Subject: [PATCH 01/10] Add Google Analytics, Dotfile, Segment and Square access-review connectors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two OAuth2 and two API-key connectors: - Google Analytics (GA4): OAuth2 with both analytics.readonly and analytics.manage.users.readonly (readonly alone 403s on the accounts list); v1alpha accessBindings enumerated at account and property level and merged by email; manual account picker (Pattern 1) with a per-connection probe and name resolver; distinct from Google Workspace. - Dotfile: API key in the X-DOTFILE-API-KEY header (Pattern 3); GET /v1/users (owner/admin, suspended_at) with a static probe. - Segment (Twilio): Public API token as Bearer with a required Region setting (US or EU) mapped to the regional host; GET /users plus per-user GET /users/{id} for roles and /invites for pending members; per-connection BuildProbeURL. - Square: OAuth2 (EMPLOYEES_READ) or a personal access token (Pattern 3); POST /v2/team-members/search returns email/status/is_owner directly, so no role resolution; custom probe and name resolver. Google Analytics and Square are confidential OAuth clients, wired into the bootstrap OAuth provider list and .env.example. Segment carries a required extra setting, so the console add-source dialog maps region onto its segmentRegion API-key input; without that mapping the value is silently dropped and the create is rejected. Cassette-backed driver tests plus unit tests for the Segment probe URL and the bootstrap OAuth provider list. Signed-off-by: Aurélien Sibiril <81782+aureliensibiril@users.noreply.github.com> --- .env.example | 5 + .../dialogs/_lib/connectorSettings.ts | 3 + pkg/accessreview/drivers/dotfile.go | 186 ++++++++++ pkg/accessreview/drivers/dotfile_test.go | 63 ++++ pkg/accessreview/drivers/google_analytics.go | 305 +++++++++++++++++ .../drivers/google_analytics_test.go | 59 ++++ pkg/accessreview/drivers/name_resolver.go | 99 ++++++ pkg/accessreview/drivers/organizations.go | 76 +++++ pkg/accessreview/drivers/segment.go | 320 ++++++++++++++++++ pkg/accessreview/drivers/segment_test.go | 64 ++++ pkg/accessreview/drivers/square.go | 174 ++++++++++ pkg/accessreview/drivers/square_test.go | 62 ++++ .../drivers/testdata/dotfile.yaml | 41 +++ .../drivers/testdata/google_analytics.yaml | 96 ++++++ .../drivers/testdata/segment.yaml | 116 +++++++ pkg/accessreview/drivers/testdata/square.yaml | 39 +++ pkg/bootstrap/builder.go | 4 + pkg/bootstrap/builder_test.go | 2 +- pkg/connector/provider/builtin.go | 4 + pkg/connector/provider/dotfile.go | 48 +++ pkg/connector/provider/google_analytics.go | 74 ++++ pkg/connector/provider/probe.go | 46 +++ pkg/connector/provider/probe_test.go | 13 + pkg/connector/provider/segment.go | 58 ++++ pkg/connector/provider/square.go | 53 +++ pkg/coredata/connector_provider.go | 118 ++++--- pkg/coredata/connector_settings.go | 19 ++ pkg/coredata/migrations/20260711T550516Z.sql | 18 + .../v1/access_source_provider_config.go | 8 + .../api/console/v1/connector_settings.go | 21 ++ .../api/console/v1/graphql/connector.graphql | 10 + 31 files changed, 2150 insertions(+), 54 deletions(-) create mode 100644 pkg/accessreview/drivers/dotfile.go create mode 100644 pkg/accessreview/drivers/dotfile_test.go create mode 100644 pkg/accessreview/drivers/google_analytics.go create mode 100644 pkg/accessreview/drivers/google_analytics_test.go create mode 100644 pkg/accessreview/drivers/segment.go create mode 100644 pkg/accessreview/drivers/segment_test.go create mode 100644 pkg/accessreview/drivers/square.go create mode 100644 pkg/accessreview/drivers/square_test.go create mode 100644 pkg/accessreview/drivers/testdata/dotfile.yaml create mode 100644 pkg/accessreview/drivers/testdata/google_analytics.yaml create mode 100644 pkg/accessreview/drivers/testdata/segment.yaml create mode 100644 pkg/accessreview/drivers/testdata/square.yaml create mode 100644 pkg/connector/provider/dotfile.go create mode 100644 pkg/connector/provider/google_analytics.go create mode 100644 pkg/connector/provider/segment.go create mode 100644 pkg/connector/provider/square.go create mode 100644 pkg/coredata/migrations/20260711T550516Z.sql diff --git a/.env.example b/.env.example index d9cb9aabee..af5655254f 100644 --- a/.env.example +++ b/.env.example @@ -144,6 +144,11 @@ # Zendesk OAuth requires a Zendesk-approved global OAuth client (Marketplace). # PROBOD_CONNECTOR_ZENDESK_CLIENT_ID= # PROBOD_CONNECTOR_ZENDESK_CLIENT_SECRET= +# Google Analytics (GA4) OAuth (distinct from Google Workspace). +# PROBOD_CONNECTOR_GOOGLE_ANALYTICS_CLIENT_ID= +# PROBOD_CONNECTOR_GOOGLE_ANALYTICS_CLIENT_SECRET= +# PROBOD_CONNECTOR_SQUARE_CLIENT_ID= +# PROBOD_CONNECTOR_SQUARE_CLIENT_SECRET= # PostHog Cloud (US + EU) OAuth needs NO config: it uses the CIMD public-client # flow (no app registration, no client_secret), auto-enabled when this # deployment is publicly reachable at PROBOD_BASE_URL. Self-hosted PostHog uses diff --git a/apps/console/src/pages/organizations/access-reviews/dialogs/_lib/connectorSettings.ts b/apps/console/src/pages/organizations/access-reviews/dialogs/_lib/connectorSettings.ts index a3b2428f3e..c5ff511dbe 100644 --- a/apps/console/src/pages/organizations/access-reviews/dialogs/_lib/connectorSettings.ts +++ b/apps/console/src/pages/organizations/access-reviews/dialogs/_lib/connectorSettings.ts @@ -77,6 +77,9 @@ export function mapAPIKeyExtraSettingToField( case "SCALEWAY": if (settingKey === "organizationId") return "scalewayOrganizationId"; break; + case "SEGMENT": + if (settingKey === "region") return "segmentRegion"; + break; case "CRISP": if (settingKey === "websiteId") return "crispWebsiteId"; break; diff --git a/pkg/accessreview/drivers/dotfile.go b/pkg/accessreview/drivers/dotfile.go new file mode 100644 index 0000000000..d1f2554b4b --- /dev/null +++ b/pkg/accessreview/drivers/dotfile.go @@ -0,0 +1,186 @@ +// 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" + "strconv" + "strings" + + "go.probo.inc/probo/pkg/coredata" +) + +const ( + dotfileAPIHost = "api.dotfile.com" + dotfileUsersPath = "/v1/users" + dotfilePageSize = 100 +) + +// DotfileDriver lists the users of a single Dotfile workspace. The API key +// (sent in the X-DOTFILE-API-KEY header by the connection transport) is bound +// to one workspace, so GET /v1/users returns every user of that workspace with +// no tenant selector. Pagination is page/limit based. +type DotfileDriver struct { + httpClient *http.Client +} + +var _ Driver = (*DotfileDriver)(nil) + +type dotfileUser struct { + ID string `json:"id"` + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + Email string `json:"email"` + Role string `json:"role"` + CreatedAt string `json:"created_at"` + // SuspendedAt is the suspension timestamp; null (decoded as empty) means + // the user is active. The endpoint returns only active users unless + // include_suspended=true is requested, so both states are enumerated. + SuspendedAt string `json:"suspended_at"` +} + +type dotfileUsersResponse struct { + Data []dotfileUser `json:"data"` + Pagination struct { + Page int `json:"page"` + Limit int `json:"limit"` + Count int `json:"count"` + } `json:"pagination"` +} + +func NewDotfileDriver(httpClient *http.Client) *DotfileDriver { + return &DotfileDriver{httpClient: httpClient} +} + +func (d *DotfileDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error) { + var records []AccountRecord + + collected := 0 + + for page := 1; page <= maxPaginationPages; page++ { + resp, err := d.fetchPage(ctx, page) + if err != nil { + return nil, err + } + + for _, u := range resp.Data { + email := strings.TrimSpace(u.Email) + if email == "" { + continue + } + + active := u.SuspendedAt == "" + + records = append(records, AccountRecord{ + Email: email, + FullName: dotfileFullName(u, email), + Roles: dotfileRoles(u.Role), + Active: &active, + IsAdmin: dotfileIsAdmin(u.Role), + MFAStatus: coredata.MFAStatusUnknown, + AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown, + AccountType: coredata.AccessReviewEntryAccountTypeUser, + CreatedAt: parseRFC3339Ptr(u.CreatedAt), + ExternalID: u.ID, + }) + } + + collected += len(resp.Data) + if len(resp.Data) == 0 || collected >= resp.Pagination.Count { + return records, nil + } + } + + return nil, fmt.Errorf("cannot list all dotfile accounts: %w", ErrPaginationLimitReached) +} + +func (d *DotfileDriver) fetchPage(ctx context.Context, page int) (*dotfileUsersResponse, error) { + q := url.Values{} + q.Set("include_suspended", "true") + q.Set("limit", strconv.Itoa(dotfilePageSize)) + q.Set("page", strconv.Itoa(page)) + + endpoint := url.URL{ + Scheme: "https", + Host: dotfileAPIHost, + Path: dotfileUsersPath, + RawQuery: q.Encode(), + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil) + if err != nil { + return nil, fmt.Errorf("cannot create dotfile 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 dotfile users request: %w", err) + } + + defer func() { + _ = httpResp.Body.Close() + }() + + if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 { + return nil, fmt.Errorf("cannot fetch dotfile users: unexpected status %d", httpResp.StatusCode) + } + + var resp dotfileUsersResponse + if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil { + return nil, fmt.Errorf("cannot decode dotfile users response: %w", err) + } + + return &resp, nil +} + +// dotfileFullName joins the user's first and last names, falling back to the +// email when Dotfile exposes neither. +func dotfileFullName(u dotfileUser, email string) string { + name := strings.TrimSpace(strings.TrimSpace(u.FirstName) + " " + strings.TrimSpace(u.LastName)) + if name == "" { + return email + } + + return name +} + +// dotfileRoles returns the user's single Dotfile role as a one-element slice +// (owner / admin / member / a custom role name), or an empty slice when none +// is set. +func dotfileRoles(role string) []string { + if r := strings.TrimSpace(role); r != "" { + return []string{r} + } + + return []string{} +} + +// dotfileIsAdmin reports whether the role grants administrative access. Dotfile +// has two system admin roles — owner (can delete the workspace) and admin (can +// change settings / invite) — while member and custom roles do not. +func dotfileIsAdmin(role string) bool { + switch strings.ToLower(strings.TrimSpace(role)) { + case "owner", "admin": + return true + default: + return false + } +} diff --git a/pkg/accessreview/drivers/dotfile_test.go b/pkg/accessreview/drivers/dotfile_test.go new file mode 100644 index 0000000000..31253826e7 --- /dev/null +++ b/pkg/accessreview/drivers/dotfile_test.go @@ -0,0 +1,63 @@ +// 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 TestDotfileDriver(t *testing.T) { + t.Parallel() + + rec := newRecorder(t, "testdata/dotfile", "DOTFILE_API_KEY") + // Dotfile authenticates via the X-DOTFILE-API-KEY header, not Authorization. + client := newVCRClientWithHeader(rec, "X-DOTFILE-API-KEY", os.Getenv("DOTFILE_API_KEY")) + + driver := NewDotfileDriver(client) + records, err := driver.ListAccounts(context.Background()) + require.NoError(t, err) + require.Len(t, records, 3) + + // Owner: active, admin, id-first ExternalID, first+last name joined. + owner := records[0] + assert.Equal(t, "a1b2c3d4-0000-4000-8000-000000000001", owner.ExternalID) + assert.Equal(t, "alice.martin@example.com", owner.Email) + assert.Equal(t, "Alice Martin", owner.FullName) + assert.True(t, owner.IsAdmin) + require.NotNil(t, owner.Active) + assert.True(t, *owner.Active) + assert.Equal(t, []string{"owner"}, owner.Roles) + + // Admin: also administrative. + admin := records[1] + assert.Equal(t, "bob.durand@example.com", admin.Email) + assert.True(t, admin.IsAdmin) + assert.Equal(t, []string{"admin"}, admin.Roles) + require.NotNil(t, admin.Active) + assert.True(t, *admin.Active) + + // Member: suspended (suspended_at set) → inactive, not admin. + member := records[2] + assert.Equal(t, "carla.petit@example.com", member.Email) + assert.False(t, member.IsAdmin) + assert.Equal(t, []string{"member"}, member.Roles) + require.NotNil(t, member.Active) + assert.False(t, *member.Active) +} diff --git a/pkg/accessreview/drivers/google_analytics.go b/pkg/accessreview/drivers/google_analytics.go new file mode 100644 index 0000000000..e2ff595508 --- /dev/null +++ b/pkg/accessreview/drivers/google_analytics.go @@ -0,0 +1,305 @@ +// 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" + "sort" + "strconv" + "strings" + + "go.probo.inc/probo/pkg/coredata" +) + +const ( + googleAnalyticsAPIHost = "analyticsadmin.googleapis.com" + googleAnalyticsPageSize = 200 + // googleAnalyticsAdminRole is the only GA4 predefined role that grants + // administrative access; viewer/analyst/editor do not, and no-cost-data / + // no-revenue-data are data restrictions rather than access levels. + googleAnalyticsAdminRole = "predefinedRoles/admin" + googleAnalyticsRolePrefix = "predefinedRoles/" +) + +// GoogleAnalyticsDriver lists the users who have access to a single GA4 account +// and every property beneath it, using the Analytics Admin API v1alpha (the +// only version that exposes accessBindings). Access is granted at two levels — +// account and property — so a user's effective roles are the union of their +// account-level binding and each of their property-level bindings, deduplicated +// by email. +type GoogleAnalyticsDriver struct { + httpClient *http.Client + accountID string +} + +var _ Driver = (*GoogleAnalyticsDriver)(nil) + +type googleAnalyticsAccessBinding struct { + // User is the email address the binding grants roles to. + User string `json:"user"` + Roles []string `json:"roles"` +} + +type googleAnalyticsBindingsResponse struct { + AccessBindings []googleAnalyticsAccessBinding `json:"accessBindings"` + NextPageToken string `json:"nextPageToken"` +} + +type googleAnalyticsProperty struct { + // Name is the resource name, e.g. "properties/67890". + Name string `json:"name"` +} + +type googleAnalyticsPropertiesResponse struct { + Properties []googleAnalyticsProperty `json:"properties"` + NextPageToken string `json:"nextPageToken"` +} + +// googleAnalyticsMember accumulates a user's roles and admin flag across their +// account-level and property-level bindings. +type googleAnalyticsMember struct { + roles map[string]struct{} + isAdmin bool +} + +func NewGoogleAnalyticsDriver(httpClient *http.Client, accountID string) *GoogleAnalyticsDriver { + return &GoogleAnalyticsDriver{ + httpClient: &http.Client{ + Transport: &retryRoundTripper{ + next: httpClient.Transport, + maxRetries: 3, + }, + }, + accountID: accountID, + } +} + +func (d *GoogleAnalyticsDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error) { + members := make(map[string]*googleAnalyticsMember) + + // Account-level bindings. + if err := d.collectBindings(ctx, members, "v1alpha", "accounts", d.accountID, "accessBindings"); err != nil { + return nil, err + } + + // Property-level bindings, one loop per property beneath the account. + propertyIDs, err := d.listProperties(ctx) + if err != nil { + return nil, err + } + + for _, propertyID := range propertyIDs { + if err := d.collectBindings(ctx, members, "v1alpha", "properties", propertyID, "accessBindings"); err != nil { + return nil, err + } + } + + return googleAnalyticsRecords(members), nil +} + +// collectBindings paginates the accessBindings collection under the given +// resource path and folds each binding into members. +func (d *GoogleAnalyticsDriver) collectBindings(ctx context.Context, members map[string]*googleAnalyticsMember, segments ...string) error { + pageToken := "" + + for range maxPaginationPages { + endpoint, err := googleAnalyticsURL(pageToken, nil, segments...) + if err != nil { + return err + } + + var resp googleAnalyticsBindingsResponse + if err := d.getJSON(ctx, endpoint, &resp); err != nil { + return err + } + + for _, b := range resp.AccessBindings { + addGoogleAnalyticsBinding(members, b.User, b.Roles) + } + + if resp.NextPageToken == "" { + return nil + } + + pageToken = resp.NextPageToken + } + + return fmt.Errorf("cannot list all google analytics access bindings: %w", ErrPaginationLimitReached) +} + +// listProperties returns the numeric IDs of every property directly beneath the +// account. +func (d *GoogleAnalyticsDriver) listProperties(ctx context.Context) ([]string, error) { + var propertyIDs []string + + pageToken := "" + filter := url.Values{"filter": {"parent:accounts/" + d.accountID}} + + for range maxPaginationPages { + endpoint, err := googleAnalyticsURL(pageToken, filter, "v1alpha", "properties") + if err != nil { + return nil, err + } + + var resp googleAnalyticsPropertiesResponse + if err := d.getJSON(ctx, endpoint, &resp); err != nil { + return nil, err + } + + for _, p := range resp.Properties { + if id := strings.TrimPrefix(p.Name, "properties/"); id != "" { + propertyIDs = append(propertyIDs, id) + } + } + + if resp.NextPageToken == "" { + return propertyIDs, nil + } + + pageToken = resp.NextPageToken + } + + return nil, fmt.Errorf("cannot list all google analytics properties: %w", ErrPaginationLimitReached) +} + +func (d *GoogleAnalyticsDriver) getJSON(ctx context.Context, endpoint string, out any) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return fmt.Errorf("cannot create google analytics request: %w", err) + } + + req.Header.Set("Accept", "application/json") + + httpResp, err := d.httpClient.Do(req) + if err != nil { + return fmt.Errorf("cannot execute google analytics request: %w", err) + } + + defer func() { + _ = httpResp.Body.Close() + }() + + if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 { + return fmt.Errorf("cannot fetch google analytics resource: unexpected status %d", httpResp.StatusCode) + } + + if err := json.NewDecoder(httpResp.Body).Decode(out); err != nil { + return fmt.Errorf("cannot decode google analytics response: %w", err) + } + + return nil +} + +// googleAnalyticsURL builds a v1alpha Admin API URL from path segments, adding +// the shared pageSize, an optional page token, and any extra query values. +func googleAnalyticsURL(pageToken string, extra url.Values, segments ...string) (string, error) { + joined, err := url.JoinPath("https://"+googleAnalyticsAPIHost, segments...) + if err != nil { + return "", fmt.Errorf("cannot build google analytics URL: %w", err) + } + + parsed, err := url.Parse(joined) + if err != nil { + return "", fmt.Errorf("cannot parse google analytics URL: %w", err) + } + + q := parsed.Query() + q.Set("pageSize", strconv.Itoa(googleAnalyticsPageSize)) + + for k, vs := range extra { + for _, v := range vs { + q.Add(k, v) + } + } + + if pageToken != "" { + q.Set("pageToken", pageToken) + } + + parsed.RawQuery = q.Encode() + + return parsed.String(), nil +} + +// addGoogleAnalyticsBinding folds one access binding into the per-email member +// map, deduplicating roles and setting the admin flag when the admin role is +// present. +func addGoogleAnalyticsBinding(members map[string]*googleAnalyticsMember, user string, roles []string) { + email := strings.ToLower(strings.TrimSpace(user)) + if email == "" { + return + } + + member, ok := members[email] + if !ok { + member = &googleAnalyticsMember{roles: make(map[string]struct{})} + members[email] = member + } + + for _, role := range roles { + role = strings.TrimSpace(role) + if role == "" { + continue + } + + member.roles[role] = struct{}{} + + if role == googleAnalyticsAdminRole { + member.isAdmin = true + } + } +} + +// googleAnalyticsRecords turns the merged member map into a deterministically +// ordered slice of AccountRecords. Active is left nil: GA4 access bindings +// carry no account-status signal. +func googleAnalyticsRecords(members map[string]*googleAnalyticsMember) []AccountRecord { + emails := make([]string, 0, len(members)) + for email := range members { + emails = append(emails, email) + } + + sort.Strings(emails) + + records := make([]AccountRecord, 0, len(members)) + + for _, email := range emails { + member := members[email] + + roles := make([]string, 0, len(member.roles)) + for role := range member.roles { + roles = append(roles, strings.TrimPrefix(role, googleAnalyticsRolePrefix)) + } + + sort.Strings(roles) + + records = append(records, AccountRecord{ + Email: email, + FullName: email, + Roles: roles, + IsAdmin: member.isAdmin, + MFAStatus: coredata.MFAStatusUnknown, + AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown, + AccountType: coredata.AccessReviewEntryAccountTypeUser, + ExternalID: email, + }) + } + + return records +} diff --git a/pkg/accessreview/drivers/google_analytics_test.go b/pkg/accessreview/drivers/google_analytics_test.go new file mode 100644 index 0000000000..fba89577c5 --- /dev/null +++ b/pkg/accessreview/drivers/google_analytics_test.go @@ -0,0 +1,59 @@ +// 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 TestGoogleAnalyticsDriver(t *testing.T) { + t.Parallel() + + rec := newRecorder(t, "testdata/google_analytics", "GOOGLE_ANALYTICS_TOKEN") + client := newVCRClient(rec, bearerAuth(os.Getenv("GOOGLE_ANALYTICS_TOKEN"))) + + driver := NewGoogleAnalyticsDriver(client, "123456") + records, err := driver.ListAccounts(context.Background()) + require.NoError(t, err) + require.Len(t, records, 3) + + // alice holds an account-level admin binding AND a property-level viewer + // binding: roles are merged, deduplicated, sorted, prefix-stripped, and the + // admin role sets IsAdmin. GA4 has no active signal → Active is nil. + alice := records[0] + assert.Equal(t, "alice@example.com", alice.Email) + assert.Equal(t, "alice@example.com", alice.ExternalID) + assert.Equal(t, "alice@example.com", alice.FullName) + assert.True(t, alice.IsAdmin) + assert.Equal(t, []string{"admin", "viewer"}, alice.Roles) + assert.Nil(t, alice.Active) + + // bob: account-level analyst only. + bob := records[1] + assert.Equal(t, "bob@example.com", bob.Email) + assert.False(t, bob.IsAdmin) + assert.Equal(t, []string{"analyst"}, bob.Roles) + + // carol: property-level analyst only (never appears at account level). + carol := records[2] + assert.Equal(t, "carol@example.com", carol.Email) + assert.False(t, carol.IsAdmin) + assert.Equal(t, []string{"analyst"}, carol.Roles) +} diff --git a/pkg/accessreview/drivers/name_resolver.go b/pkg/accessreview/drivers/name_resolver.go index 4541468a3f..41857b6a46 100644 --- a/pkg/accessreview/drivers/name_resolver.go +++ b/pkg/accessreview/drivers/name_resolver.go @@ -1633,3 +1633,102 @@ func (r *crispNameResolver) ResolveInstanceName(ctx context.Context) (string, er return resp.Data.Name, nil } + +// squareNameResolver resolves the Square merchant's business name via +// GET /v2/merchants/me. A Square token — OAuth or PAT — is scoped to a single +// merchant, so "me" resolves it for both connection kinds. +type squareNameResolver struct { + httpClient *http.Client +} + +func NewSquareNameResolver(httpClient *http.Client) NameResolver { + return &squareNameResolver{httpClient: httpClient} +} + +func (r *squareNameResolver) ResolveInstanceName(ctx context.Context) (string, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://connect.squareup.com/v2/merchants/me", nil) + if err != nil { + return "", fmt.Errorf("cannot create square merchant request: %w", err) + } + + req.Header.Set("Accept", "application/json") + req.Header.Set("Square-Version", squareAPIVersion) + + httpResp, err := r.httpClient.Do(req) + if err != nil { + return "", fmt.Errorf("cannot execute square merchant request: %w", err) + } + + defer func() { + _ = httpResp.Body.Close() + }() + + // A non-2xx (revoked token, missing scope) is terminal: keep the generic + // source name rather than make the source-name worker retry forever. + if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 { + return "", nil + } + + var resp struct { + Merchant struct { + BusinessName string `json:"business_name"` + } `json:"merchant"` + } + if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil { + return "", fmt.Errorf("cannot decode square merchant response: %w", err) + } + + return resp.Merchant.BusinessName, nil +} + +// googleAnalyticsNameResolver resolves a GA4 account's display name. +type googleAnalyticsNameResolver struct { + httpClient *http.Client + accountID string +} + +func NewGoogleAnalyticsNameResolver(httpClient *http.Client, accountID string) NameResolver { + return &googleAnalyticsNameResolver{httpClient: httpClient, accountID: accountID} +} + +func (r *googleAnalyticsNameResolver) ResolveInstanceName(ctx context.Context) (string, error) { + if r.accountID == "" { + return "", nil + } + + endpoint, err := url.JoinPath("https://"+googleAnalyticsAPIHost, "v1alpha", "accounts", url.PathEscape(r.accountID)) + if err != nil { + return "", fmt.Errorf("cannot build google analytics account URL: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return "", fmt.Errorf("cannot create google analytics account request: %w", err) + } + + req.Header.Set("Accept", "application/json") + + httpResp, err := r.httpClient.Do(req) + if err != nil { + return "", fmt.Errorf("cannot execute google analytics account request: %w", err) + } + + defer func() { + _ = httpResp.Body.Close() + }() + + // A non-2xx (revoked token, renamed/deleted account) is terminal: keep the + // generic source name rather than retry forever. + if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 { + return "", nil + } + + var resp struct { + DisplayName string `json:"displayName"` + } + if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil { + return "", fmt.Errorf("cannot decode google analytics account response: %w", err) + } + + return resp.DisplayName, nil +} diff --git a/pkg/accessreview/drivers/organizations.go b/pkg/accessreview/drivers/organizations.go index edb75826e8..5498aa4b6f 100644 --- a/pkg/accessreview/drivers/organizations.go +++ b/pkg/accessreview/drivers/organizations.go @@ -19,7 +19,9 @@ import ( "encoding/json" "fmt" "net/http" + "net/url" "strconv" + "strings" ) // Organization represents a tenant/workspace/team/group surfaced by a @@ -464,3 +466,77 @@ func ListClickUpOrganizations(ctx context.Context, httpClient *http.Client) ([]O return result, nil } + +// ListGoogleAnalyticsOrganizations fetches the GA4 accounts the authenticated +// Google user can access, surfacing each account's numeric ID as the picker +// slug. Listing accounts requires the analytics.readonly scope. +func ListGoogleAnalyticsOrganizations(ctx context.Context, httpClient *http.Client) ([]Organization, error) { + var orgs []Organization + + pageToken := "" + + for range maxPaginationPages { + q := url.Values{} + q.Set("pageSize", strconv.Itoa(googleAnalyticsPageSize)) + + if pageToken != "" { + q.Set("pageToken", pageToken) + } + + endpoint := url.URL{ + Scheme: "https", + Host: googleAnalyticsAPIHost, + Path: "/v1alpha/accounts", + RawQuery: q.Encode(), + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil) + if err != nil { + return nil, fmt.Errorf("cannot create google analytics accounts request: %w", err) + } + + req.Header.Set("Accept", "application/json") + + resp, err := httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("cannot fetch google analytics accounts: %w", err) + } + + var out struct { + Accounts []struct { + Name string `json:"name"` + DisplayName string `json:"displayName"` + } `json:"accounts"` + NextPageToken string `json:"nextPageToken"` + } + + decodeErr := json.NewDecoder(resp.Body).Decode(&out) + status := resp.StatusCode + _ = resp.Body.Close() + + if status != http.StatusOK { + return nil, fmt.Errorf("cannot fetch google analytics accounts: unexpected status %d", status) + } + + if decodeErr != nil { + return nil, fmt.Errorf("cannot decode google analytics accounts response: %w", decodeErr) + } + + for _, a := range out.Accounts { + id := strings.TrimPrefix(a.Name, "accounts/") + if id == "" { + continue + } + + orgs = append(orgs, Organization{Slug: id, DisplayName: a.DisplayName}) + } + + if out.NextPageToken == "" { + return orgs, nil + } + + pageToken = out.NextPageToken + } + + return nil, fmt.Errorf("cannot list all google analytics accounts: %w", ErrPaginationLimitReached) +} diff --git a/pkg/accessreview/drivers/segment.go b/pkg/accessreview/drivers/segment.go new file mode 100644 index 0000000000..39c1b7841e --- /dev/null +++ b/pkg/accessreview/drivers/segment.go @@ -0,0 +1,320 @@ +// 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" + "sort" + "strconv" + "strings" + + "go.probo.inc/probo/pkg/coredata" +) + +const ( + segmentPageSize = 200 + // segmentWorkspaceOwnerRole is the only Segment role that grants + // workspace-wide administrative access; the resource-scoped *Admin roles + // (Source Admin, Warehouse Admin, …) administer a single resource, not the + // workspace. + segmentWorkspaceOwnerRole = "Workspace Owner" +) + +// SegmentDriver lists the members of a single Twilio Segment workspace. The +// Public API token (sent as Authorization: Bearer by the connection transport) +// is bound to one workspace on one regional host. GET /users returns members +// without their roles, so each member's roles are fetched with a follow-up GET +// /users/{id} (an unavoidable N+1); GET /invites adds pending invitations as +// inactive members. +type SegmentDriver struct { + httpClient *http.Client + baseURL string +} + +var _ Driver = (*SegmentDriver)(nil) + +type segmentUser struct { + ID string `json:"id"` + Name string `json:"name"` + Email string `json:"email"` +} + +type segmentPermission struct { + RoleName string `json:"roleName"` +} + +type segmentPaginationOut struct { + // Next is absent (nil) on the last page. + Next *string `json:"next"` +} + +type segmentUsersResponse struct { + Data struct { + Users []segmentUser `json:"users"` + Pagination segmentPaginationOut `json:"pagination"` + } `json:"data"` +} + +type segmentUserResponse struct { + Data struct { + User struct { + Permissions []segmentPermission `json:"permissions"` + } `json:"user"` + } `json:"data"` +} + +type segmentInvitesResponse struct { + Data struct { + Invites []string `json:"invites"` + Pagination segmentPaginationOut `json:"pagination"` + } `json:"data"` +} + +func NewSegmentDriver(httpClient *http.Client, baseURL string) *SegmentDriver { + return &SegmentDriver{ + httpClient: &http.Client{ + Transport: &retryRoundTripper{ + next: httpClient.Transport, + maxRetries: 3, + }, + }, + baseURL: baseURL, + } +} + +func (d *SegmentDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error) { + base, err := url.Parse(d.baseURL) + if err != nil { + return nil, fmt.Errorf("cannot parse segment base URL: %w", err) + } + + users, err := d.listUsers(ctx, base) + if err != nil { + return nil, err + } + + records := make([]AccountRecord, 0, len(users)) + + for _, u := range users { + email := strings.TrimSpace(u.Email) + if email == "" { + continue + } + + perms, err := d.userPermissions(ctx, base, u.ID) + if err != nil { + return nil, err + } + + roles, isAdmin := segmentRolesAndAdmin(perms) + active := true + + records = append(records, AccountRecord{ + Email: email, + FullName: segmentFullName(u.Name, email), + Roles: roles, + Active: &active, + IsAdmin: isAdmin, + MFAStatus: coredata.MFAStatusUnknown, + AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown, + AccountType: coredata.AccessReviewEntryAccountTypeUser, + ExternalID: u.ID, + }) + } + + invites, err := d.listInvites(ctx, base) + if err != nil { + return nil, err + } + + for _, email := range invites { + email = strings.TrimSpace(email) + if email == "" { + continue + } + + // A pending invite carries no role and no stable id at the workspace + // level, so it is surfaced as an inactive member keyed by email. + inactive := false + + records = append(records, AccountRecord{ + Email: email, + FullName: email, + Roles: []string{}, + Active: &inactive, + MFAStatus: coredata.MFAStatusUnknown, + AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown, + AccountType: coredata.AccessReviewEntryAccountTypeUser, + ExternalID: email, + }) + } + + return records, nil +} + +func (d *SegmentDriver) listUsers(ctx context.Context, base *url.URL) ([]segmentUser, error) { + var users []segmentUser + + cursor := "" + + for range maxPaginationPages { + endpoint := *base + endpoint.Path = "/users" + + q := url.Values{} + q.Set("pagination.count", strconv.Itoa(segmentPageSize)) + + if cursor != "" { + q.Set("pagination.cursor", cursor) + } + + endpoint.RawQuery = q.Encode() + + var resp segmentUsersResponse + if err := d.getJSON(ctx, endpoint.String(), &resp); err != nil { + return nil, err + } + + users = append(users, resp.Data.Users...) + + if resp.Data.Pagination.Next == nil || *resp.Data.Pagination.Next == "" { + return users, nil + } + + cursor = *resp.Data.Pagination.Next + } + + return nil, fmt.Errorf("cannot list all segment users: %w", ErrPaginationLimitReached) +} + +func (d *SegmentDriver) userPermissions(ctx context.Context, base *url.URL, userID string) ([]segmentPermission, error) { + endpoint := *base + // Path holds the decoded value; endpoint.String() escapes the id once. + endpoint.Path = "/users/" + userID + + var resp segmentUserResponse + if err := d.getJSON(ctx, endpoint.String(), &resp); err != nil { + return nil, err + } + + return resp.Data.User.Permissions, nil +} + +func (d *SegmentDriver) listInvites(ctx context.Context, base *url.URL) ([]string, error) { + var invites []string + + cursor := "" + + for range maxPaginationPages { + endpoint := *base + endpoint.Path = "/invites" + + q := url.Values{} + q.Set("pagination.count", strconv.Itoa(segmentPageSize)) + + if cursor != "" { + q.Set("pagination.cursor", cursor) + } + + endpoint.RawQuery = q.Encode() + + var resp segmentInvitesResponse + if err := d.getJSON(ctx, endpoint.String(), &resp); err != nil { + return nil, err + } + + invites = append(invites, resp.Data.Invites...) + + if resp.Data.Pagination.Next == nil || *resp.Data.Pagination.Next == "" { + return invites, nil + } + + cursor = *resp.Data.Pagination.Next + } + + return nil, fmt.Errorf("cannot list all segment invites: %w", ErrPaginationLimitReached) +} + +func (d *SegmentDriver) getJSON(ctx context.Context, endpoint string, out any) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return fmt.Errorf("cannot create segment request: %w", err) + } + + req.Header.Set("Accept", "application/json") + + httpResp, err := d.httpClient.Do(req) + if err != nil { + return fmt.Errorf("cannot execute segment request: %w", err) + } + + defer func() { + _ = httpResp.Body.Close() + }() + + if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 { + return fmt.Errorf("cannot fetch segment resource: unexpected status %d", httpResp.StatusCode) + } + + if err := json.NewDecoder(httpResp.Body).Decode(out); err != nil { + return fmt.Errorf("cannot decode segment response: %w", err) + } + + return nil +} + +// segmentFullName returns the member's name, falling back to the email when +// Segment stores an empty name. +func segmentFullName(name, email string) string { + if n := strings.TrimSpace(name); n != "" { + return n + } + + return email +} + +// segmentRolesAndAdmin collapses a member's permission entries into a +// de-duplicated, sorted set of role names and reports whether any of them is +// the workspace-level Workspace Owner role. +func segmentRolesAndAdmin(perms []segmentPermission) ([]string, bool) { + seen := make(map[string]struct{}) + isAdmin := false + + for _, p := range perms { + name := strings.TrimSpace(p.RoleName) + if name == "" { + continue + } + + seen[name] = struct{}{} + + if strings.EqualFold(name, segmentWorkspaceOwnerRole) { + isAdmin = true + } + } + + roles := make([]string, 0, len(seen)) + for name := range seen { + roles = append(roles, name) + } + + sort.Strings(roles) + + return roles, isAdmin +} diff --git a/pkg/accessreview/drivers/segment_test.go b/pkg/accessreview/drivers/segment_test.go new file mode 100644 index 0000000000..ad958c8f62 --- /dev/null +++ b/pkg/accessreview/drivers/segment_test.go @@ -0,0 +1,64 @@ +// 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 TestSegmentDriver(t *testing.T) { + t.Parallel() + + rec := newRecorder(t, "testdata/segment", "SEGMENT_API_TOKEN") + client := newVCRClient(rec, bearerAuth(os.Getenv("SEGMENT_API_TOKEN"))) + + driver := NewSegmentDriver(client, "https://api.segmentapis.com") + records, err := driver.ListAccounts(context.Background()) + require.NoError(t, err) + require.Len(t, records, 3) + + // First user: empty name falls back to email; Workspace Owner ⇒ admin. + owner := records[0] + assert.Equal(t, "sgJDWk3K21k6LE3tLU9nRK", owner.ExternalID) + assert.Equal(t, "papi@example.com", owner.Email) + assert.Equal(t, "papi@example.com", owner.FullName) + assert.True(t, owner.IsAdmin) + assert.Equal(t, []string{"Workspace Owner"}, owner.Roles) + require.NotNil(t, owner.Active) + assert.True(t, *owner.Active) + + // Second user: named; resource-scoped read-only role ⇒ not admin. + member := records[1] + assert.Equal(t, "i2VTJURQprNfqdwjLFPWYx", member.ExternalID) + assert.Equal(t, "Sloth", member.FullName) + assert.False(t, member.IsAdmin) + assert.Equal(t, []string{"Source Read-only"}, member.Roles) + require.NotNil(t, member.Active) + assert.True(t, *member.Active) + + // Pending invite: inactive, no roles, keyed by email. + invite := records[2] + assert.Equal(t, "foo@example.com", invite.Email) + assert.Equal(t, "foo@example.com", invite.ExternalID) + assert.False(t, invite.IsAdmin) + assert.Empty(t, invite.Roles) + require.NotNil(t, invite.Active) + assert.False(t, *invite.Active) +} diff --git a/pkg/accessreview/drivers/square.go b/pkg/accessreview/drivers/square.go new file mode 100644 index 0000000000..a4a7828554 --- /dev/null +++ b/pkg/accessreview/drivers/square.go @@ -0,0 +1,174 @@ +// 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 ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "strings" + + "go.probo.inc/probo/pkg/coredata" +) + +const ( + squareTeamMembersSearchURL = "https://connect.squareup.com/v2/team-members/search" + // squareAPIVersion pins the request version so behavior is deterministic + // rather than following the application's console default. + squareAPIVersion = "2026-05-20" + squareSearchLimit = 200 +) + +// SquareDriver lists the team members of a single Square merchant. A Square +// token — OAuth Bearer or Personal Access Token — is always scoped to one +// merchant, so POST /v2/team-members/search returns every team member of that +// merchant with no tenant selector. The search returns is_owner directly, so +// there is no role resolution. +type SquareDriver struct { + httpClient *http.Client +} + +var _ Driver = (*SquareDriver)(nil) + +type squareTeamMember struct { + ID string `json:"id"` + IsOwner bool `json:"is_owner"` + Status string `json:"status"` + GivenName string `json:"given_name"` + FamilyName string `json:"family_name"` + EmailAddress string `json:"email_address"` + CreatedAt string `json:"created_at"` +} + +type squareSearchResponse struct { + TeamMembers []squareTeamMember `json:"team_members"` + Cursor string `json:"cursor"` +} + +func NewSquareDriver(httpClient *http.Client) *SquareDriver { + return &SquareDriver{ + httpClient: &http.Client{ + Transport: &retryRoundTripper{ + next: httpClient.Transport, + maxRetries: 3, + }, + }, + } +} + +func (d *SquareDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error) { + var records []AccountRecord + + cursor := "" + + for range maxPaginationPages { + page, err := d.searchTeamMembers(ctx, cursor) + if err != nil { + return nil, err + } + + for _, m := range page.TeamMembers { + email := strings.TrimSpace(m.EmailAddress) + if email == "" { + continue + } + + role := "member" + if m.IsOwner { + role = "owner" + } + + records = append(records, AccountRecord{ + Email: email, + FullName: squareFullName(m, email), + Roles: ownerMemberRoles(role), + Active: activeFromStatus(m.Status), + IsAdmin: m.IsOwner, + MFAStatus: coredata.MFAStatusUnknown, + AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown, + AccountType: coredata.AccessReviewEntryAccountTypeUser, + CreatedAt: parseRFC3339Ptr(m.CreatedAt), + ExternalID: m.ID, + }) + } + + if page.Cursor == "" { + return records, nil + } + + cursor = page.Cursor + } + + return nil, fmt.Errorf("cannot list all square accounts: %w", ErrPaginationLimitReached) +} + +func (d *SquareDriver) searchTeamMembers(ctx context.Context, cursor string) (*squareSearchResponse, error) { + // No query filter: return team members of every status (ACTIVE and + // INACTIVE) so deactivated members are reviewed too. + reqBody := struct { + Limit int `json:"limit"` + Cursor string `json:"cursor,omitempty"` + }{ + Limit: squareSearchLimit, + Cursor: cursor, + } + + body, err := json.Marshal(reqBody) + if err != nil { + return nil, fmt.Errorf("cannot marshal square search request: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, squareTeamMembersSearchURL, bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("cannot create square team members request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + req.Header.Set("Square-Version", squareAPIVersion) + + httpResp, err := d.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("cannot execute square team members request: %w", err) + } + + defer func() { + _ = httpResp.Body.Close() + }() + + if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 { + return nil, fmt.Errorf("cannot fetch square team members: unexpected status %d", httpResp.StatusCode) + } + + var resp squareSearchResponse + if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil { + return nil, fmt.Errorf("cannot decode square team members response: %w", err) + } + + return &resp, nil +} + +// squareFullName joins the team member's given and family names, falling back +// to the email when Square exposes neither. +func squareFullName(m squareTeamMember, email string) string { + name := strings.TrimSpace(strings.TrimSpace(m.GivenName) + " " + strings.TrimSpace(m.FamilyName)) + if name == "" { + return email + } + + return name +} diff --git a/pkg/accessreview/drivers/square_test.go b/pkg/accessreview/drivers/square_test.go new file mode 100644 index 0000000000..05cb214a5f --- /dev/null +++ b/pkg/accessreview/drivers/square_test.go @@ -0,0 +1,62 @@ +// 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 TestSquareDriver(t *testing.T) { + t.Parallel() + + rec := newRecorder(t, "testdata/square", "SQUARE_ACCESS_TOKEN") + client := newVCRClient(rec, bearerAuth(os.Getenv("SQUARE_ACCESS_TOKEN"))) + + driver := NewSquareDriver(client) + records, err := driver.ListAccounts(context.Background()) + require.NoError(t, err) + require.Len(t, records, 3) + + // Non-owner, ACTIVE → Member, active, not admin. + member := records[0] + assert.Equal(t, "-3oZQKPKVk6gUXU_V5Qa", member.ExternalID) + assert.Equal(t, "sherlock.holmes@example.com", member.Email) + assert.Equal(t, "Sherlock Holmes", member.FullName) + assert.False(t, member.IsAdmin) + assert.Equal(t, []string{"Member"}, member.Roles) + require.NotNil(t, member.Active) + assert.True(t, *member.Active) + + // Owner → is_owner drives IsAdmin directly. + owner := records[1] + assert.Equal(t, "Pw67AzUomYUdF04AN17i", owner.ExternalID) + assert.Equal(t, "john.watson@example.com", owner.Email) + assert.True(t, owner.IsAdmin) + assert.Equal(t, []string{"Owner"}, owner.Roles) + require.NotNil(t, owner.Active) + assert.True(t, *owner.Active) + + // INACTIVE member → active=false. + inactive := records[2] + assert.Equal(t, "martha.hudson@example.com", inactive.Email) + assert.False(t, inactive.IsAdmin) + require.NotNil(t, inactive.Active) + assert.False(t, *inactive.Active) +} diff --git a/pkg/accessreview/drivers/testdata/dotfile.yaml b/pkg/accessreview/drivers/testdata/dotfile.yaml new file mode 100644 index 0000000000..0a664d1eb8 --- /dev/null +++ b/pkg/accessreview/drivers/testdata/dotfile.yaml @@ -0,0 +1,41 @@ +--- +# Hand-authored cassette for GET /v1/users (Dotfile, X-DOTFILE-API-KEY header, +# stripped by the recorder). include_suspended=true returns active and suspended +# users in one page; count=3 terminates pagination. Three synthetic users cover +# owner/admin/member and active vs suspended (suspended_at set → inactive). The +# User shape (id, first_name, last_name, email, role, created_at, suspended_at) +# mirrors the documented schema. +version: 2 +interactions: + - id: 0 + request: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + content_length: 0 + host: api.dotfile.com + form: + include_suspended: + - "true" + limit: + - "100" + page: + - "1" + headers: + Accept: + - application/json + url: https://api.dotfile.com/v1/users?include_suspended=true&limit=100&page=1 + method: GET + response: + proto: HTTP/2.0 + proto_major: 2 + proto_minor: 0 + content_length: -1 + uncompressed: true + body: '{"data":[{"id":"a1b2c3d4-0000-4000-8000-000000000001","first_name":"Alice","last_name":"Martin","email":"alice.martin@example.com","role":"owner","created_at":"2023-01-31T13:30:00.000Z","suspended_at":null},{"id":"a1b2c3d4-0000-4000-8000-000000000002","first_name":"Bob","last_name":"Durand","email":"bob.durand@example.com","role":"admin","created_at":"2023-02-15T09:00:00.000Z","suspended_at":null},{"id":"a1b2c3d4-0000-4000-8000-000000000003","first_name":"Carla","last_name":"Petit","email":"carla.petit@example.com","role":"member","created_at":"2023-03-01T11:45:00.000Z","suspended_at":"2024-12-11T08:00:00.000Z"}],"pagination":{"page":1,"limit":100,"count":3}}' + headers: + Content-Type: + - application/json + status: 200 OK + code: 200 + duration: 100ms diff --git a/pkg/accessreview/drivers/testdata/google_analytics.yaml b/pkg/accessreview/drivers/testdata/google_analytics.yaml new file mode 100644 index 0000000000..a375111d77 --- /dev/null +++ b/pkg/accessreview/drivers/testdata/google_analytics.yaml @@ -0,0 +1,96 @@ +--- +# Hand-authored cassette for the GA4 Admin API v1alpha (Bearer token stripped by +# the recorder). The driver lists account-level accessBindings, then the +# properties beneath the account, then each property's accessBindings, merging a +# user's roles across levels by email. alice appears at both levels (admin + +# viewer) to exercise the merge; carol appears only at the property level. The +# AccessBinding shape (name, user, roles[]) and property list shape mirror the +# live API. +version: 2 +interactions: + - id: 0 + request: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + content_length: 0 + host: analyticsadmin.googleapis.com + form: + pageSize: + - "200" + headers: + Accept: + - application/json + url: https://analyticsadmin.googleapis.com/v1alpha/accounts/123456/accessBindings?pageSize=200 + method: GET + response: + proto: HTTP/2.0 + proto_major: 2 + proto_minor: 0 + content_length: -1 + uncompressed: true + body: '{"accessBindings":[{"name":"accounts/123456/accessBindings/abc123","user":"alice@example.com","roles":["predefinedRoles/admin"]},{"name":"accounts/123456/accessBindings/def456","user":"bob@example.com","roles":["predefinedRoles/analyst"]}]}' + headers: + Content-Type: + - application/json + status: 200 OK + code: 200 + duration: 100ms + - id: 1 + request: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + content_length: 0 + host: analyticsadmin.googleapis.com + form: + filter: + - parent:accounts/123456 + pageSize: + - "200" + headers: + Accept: + - application/json + url: https://analyticsadmin.googleapis.com/v1alpha/properties?filter=parent%3Aaccounts%2F123456&pageSize=200 + method: GET + response: + proto: HTTP/2.0 + proto_major: 2 + proto_minor: 0 + content_length: -1 + uncompressed: true + body: '{"properties":[{"name":"properties/67890","displayName":"Acme Website","parent":"accounts/123456"}]}' + headers: + Content-Type: + - application/json + status: 200 OK + code: 200 + duration: 100ms + - id: 2 + request: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + content_length: 0 + host: analyticsadmin.googleapis.com + form: + pageSize: + - "200" + headers: + Accept: + - application/json + url: https://analyticsadmin.googleapis.com/v1alpha/properties/67890/accessBindings?pageSize=200 + method: GET + response: + proto: HTTP/2.0 + proto_major: 2 + proto_minor: 0 + content_length: -1 + uncompressed: true + body: '{"accessBindings":[{"name":"properties/67890/accessBindings/ghi789","user":"alice@example.com","roles":["predefinedRoles/viewer"]},{"name":"properties/67890/accessBindings/jkl012","user":"carol@example.com","roles":["predefinedRoles/analyst"]}]}' + headers: + Content-Type: + - application/json + status: 200 OK + code: 200 + duration: 100ms diff --git a/pkg/accessreview/drivers/testdata/segment.yaml b/pkg/accessreview/drivers/testdata/segment.yaml new file mode 100644 index 0000000000..a30de74fd7 --- /dev/null +++ b/pkg/accessreview/drivers/testdata/segment.yaml @@ -0,0 +1,116 @@ +--- +# Hand-authored cassette for the Segment Public API (US host; Bearer token +# stripped by the recorder). GET /users returns members without roles, so each +# member's roles are fetched via GET /users/{id} (the N+1); GET /invites adds a +# pending invite as an inactive member. Two synthetic users cover the +# Workspace Owner (admin) and a resource-scoped read-only role; the envelope +# (data.users / data.user.permissions / data.invites, data.pagination.next) and +# the UserV1 / PermissionV1 shapes mirror the live API. +version: 2 +interactions: + - id: 0 + request: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + content_length: 0 + host: api.segmentapis.com + form: + pagination.count: + - "200" + headers: + Accept: + - application/json + url: https://api.segmentapis.com/users?pagination.count=200 + method: GET + response: + proto: HTTP/2.0 + proto_major: 2 + proto_minor: 0 + content_length: -1 + uncompressed: true + body: '{"data":{"users":[{"id":"sgJDWk3K21k6LE3tLU9nRK","name":"","email":"papi@example.com"},{"id":"i2VTJURQprNfqdwjLFPWYx","name":"Sloth","email":"sloth@example.com"}],"pagination":{"current":"MA=="}}}' + headers: + Content-Type: + - application/json + status: 200 OK + code: 200 + duration: 100ms + - id: 1 + request: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + content_length: 0 + host: api.segmentapis.com + headers: + Accept: + - application/json + url: https://api.segmentapis.com/users/sgJDWk3K21k6LE3tLU9nRK + method: GET + response: + proto: HTTP/2.0 + proto_major: 2 + proto_minor: 0 + content_length: -1 + uncompressed: true + body: '{"data":{"user":{"id":"sgJDWk3K21k6LE3tLU9nRK","name":"","email":"papi@example.com","permissions":[{"roleId":"1WDUuRLxv84rrfCNUwvkrRtkxnS","roleName":"Workspace Owner","resources":[{"id":"9aQ1Lj62S4bomZKLF4DPqW","type":"WORKSPACE","labels":[]}]}]}}}' + headers: + Content-Type: + - application/json + status: 200 OK + code: 200 + duration: 100ms + - id: 2 + request: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + content_length: 0 + host: api.segmentapis.com + headers: + Accept: + - application/json + url: https://api.segmentapis.com/users/i2VTJURQprNfqdwjLFPWYx + method: GET + response: + proto: HTTP/2.0 + proto_major: 2 + proto_minor: 0 + content_length: -1 + uncompressed: true + body: '{"data":{"user":{"id":"i2VTJURQprNfqdwjLFPWYx","name":"Sloth","email":"sloth@example.com","permissions":[{"roleId":"2ABxYz84rrfCNUwvkrRtkxnT","roleName":"Source Read-only","resources":[{"id":"src_1a2b3c","type":"SOURCE","labels":[]}]}]}}}' + headers: + Content-Type: + - application/json + status: 200 OK + code: 200 + duration: 100ms + - id: 3 + request: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + content_length: 0 + host: api.segmentapis.com + form: + pagination.count: + - "200" + headers: + Accept: + - application/json + url: https://api.segmentapis.com/invites?pagination.count=200 + method: GET + response: + proto: HTTP/2.0 + proto_major: 2 + proto_minor: 0 + content_length: -1 + uncompressed: true + body: '{"data":{"invites":["foo@example.com"],"pagination":{"current":"MA=="}}}' + headers: + Content-Type: + - application/json + status: 200 OK + code: 200 + duration: 100ms diff --git a/pkg/accessreview/drivers/testdata/square.yaml b/pkg/accessreview/drivers/testdata/square.yaml new file mode 100644 index 0000000000..16cccf806e --- /dev/null +++ b/pkg/accessreview/drivers/testdata/square.yaml @@ -0,0 +1,39 @@ +--- +# Hand-authored cassette for POST /v2/team-members/search (Square, Bearer token +# stripped by the recorder). No query filter is sent, so the request body is the +# minimal {"limit":200}; an absent response cursor terminates pagination. Three +# synthetic team members cover owner/non-owner and ACTIVE/INACTIVE. The +# team_member shape (id, is_owner, status, given_name, family_name, +# email_address, created_at) mirrors the SearchTeamMembers response. +version: 2 +interactions: + - id: 0 + request: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + content_length: 13 + host: connect.squareup.com + body: '{"limit":200}' + headers: + Accept: + - application/json + Content-Type: + - application/json + Square-Version: + - "2026-05-20" + url: https://connect.squareup.com/v2/team-members/search + method: POST + response: + proto: HTTP/2.0 + proto_major: 2 + proto_minor: 0 + content_length: -1 + uncompressed: true + body: '{"team_members":[{"id":"-3oZQKPKVk6gUXU_V5Qa","is_owner":false,"status":"ACTIVE","given_name":"Sherlock","family_name":"Holmes","email_address":"sherlock.holmes@example.com","created_at":"2020-03-24T18:14:26Z"},{"id":"Pw67AzUomYUdF04AN17i","is_owner":true,"status":"ACTIVE","given_name":"John","family_name":"Watson","email_address":"john.watson@example.com","created_at":"2020-03-24T18:14:26Z"},{"id":"Xk92LmQ0pRsTuvWxYz01","is_owner":false,"status":"INACTIVE","given_name":"Martha","family_name":"Hudson","email_address":"martha.hudson@example.com","created_at":"2021-06-10T09:00:00Z"}]}' + headers: + Content-Type: + - application/json + status: 200 OK + code: 200 + duration: 100ms diff --git a/pkg/bootstrap/builder.go b/pkg/bootstrap/builder.go index fd3fc89e33..ea68f21ed9 100644 --- a/pkg/bootstrap/builder.go +++ b/pkg/bootstrap/builder.go @@ -477,6 +477,8 @@ func (b *Builder) Build() (*probodconfig.FullConfig, error) { "DATADOG", "ZENDESK", "LINEAR", + "GOOGLE_ANALYTICS", + "SQUARE", } { clientID := b.resolver.getEnv("PROBOD_CONNECTOR_" + provider + "_CLIENT_ID") if clientID == "" { @@ -601,6 +603,8 @@ func (b *Builder) validateRequired() error { {"CONNECTOR_DATADOG", []string{"CLIENT_SECRET"}}, {"CONNECTOR_ZENDESK", []string{"CLIENT_SECRET"}}, {"CONNECTOR_LINEAR", []string{"CLIENT_SECRET"}}, + {"CONNECTOR_GOOGLE_ANALYTICS", []string{"CLIENT_SECRET"}}, + {"CONNECTOR_SQUARE", []string{"CLIENT_SECRET"}}, {"CONNECTOR_VERCEL", []string{"CLIENT_SECRET", "INTEGRATION_SLUG"}}, } diff --git a/pkg/bootstrap/builder_test.go b/pkg/bootstrap/builder_test.go index 1dd86df4cb..b44ad16172 100644 --- a/pkg/bootstrap/builder_test.go +++ b/pkg/bootstrap/builder_test.go @@ -605,7 +605,7 @@ func TestBuilder_Build_AccessReviewConnectors(t *testing.T) { providers := []string{ "GITLAB", "BITBUCKET", "HEROKU", "PAGERDUTY", "ASANA", "NETLIFY", "CLICKUP", "MONDAY", "DATADOG", - "ZENDESK", "LINEAR", + "ZENDESK", "LINEAR", "GOOGLE_ANALYTICS", "SQUARE", } env := requiredEnv() diff --git a/pkg/connector/provider/builtin.go b/pkg/connector/provider/builtin.go index 1afcaeaa84..6ad4270cba 100644 --- a/pkg/connector/provider/builtin.go +++ b/pkg/connector/provider/builtin.go @@ -38,9 +38,11 @@ func NewBuiltinRegistry() *Registry { datadogRegistration(), deepgramRegistration(), docusignRegistration(), + dotfileRegistration(), grafanaRegistration(), githubRegistration(), gitlabRegistration(), + googleAnalyticsRegistration(), googleWorkspaceRegistration(), herokuRegistration(), hubspotRegistration(), @@ -67,10 +69,12 @@ func NewBuiltinRegistry() *Registry { renderRegistration(), resendRegistration(), scalewayRegistration(), + segmentRegistration(), sendgridRegistration(), sentryRegistration(), signozRegistration(), slackRegistration(), + squareRegistration(), supabaseRegistration(), tailscaleRegistration(), tallyRegistration(), diff --git a/pkg/connector/provider/dotfile.go b/pkg/connector/provider/dotfile.go new file mode 100644 index 0000000000..29844f5c5d --- /dev/null +++ b/pkg/connector/provider/dotfile.go @@ -0,0 +1,48 @@ +// 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 dotfileRegistration() *Registration { + return &Registration{ + Provider: coredata.ConnectorProviderDotfile, + DisplayName: "Dotfile", + SupportsAPIKey: true, + // Dotfile authenticates with the API key in the X-DOTFILE-API-KEY + // header rather than Authorization: Bearer. APIKeyHeader makes the + // APIKeyConnection send that header and omit Authorization. The key is + // bound to one workspace, so there is nothing to pick (Pattern 3): no + // settings struct, no picker. + APIKeyHeader: "X-DOTFILE-API-KEY", + // ProbeURL lets the connection-status check confirm the key with a + // lightweight GET; the transport attaches X-DOTFILE-API-KEY and a dead + // key returns 401. + ProbeURL: "https://api.dotfile.com/v1/users?limit=1", + // No NewNameResolver: the users endpoint carries no workspace name, so + // the source keeps its generic name. + NewDriver: func(_ context.Context, c *http.Client, _ *coredata.Connector, _ *log.Logger) (drivers.Driver, error) { + return drivers.NewDotfileDriver(c), nil + }, + } +} diff --git a/pkg/connector/provider/google_analytics.go b/pkg/connector/provider/google_analytics.go new file mode 100644 index 0000000000..16f38309c6 --- /dev/null +++ b/pkg/connector/provider/google_analytics.go @@ -0,0 +1,74 @@ +// 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" + "fmt" + "net/http" + + "go.gearno.de/kit/log" + + "go.probo.inc/probo/pkg/accessreview/drivers" + "go.probo.inc/probo/pkg/coredata" +) + +func googleAnalyticsRegistration() *Registration { + return &Registration{ + Provider: coredata.ConnectorProviderGoogleAnalytics, + DisplayName: "Google Analytics", + AuthURL: "https://accounts.google.com/o/oauth2/v2/auth", + TokenURL: "https://oauth2.googleapis.com/token", + ExtraAuthParams: map[string]string{ + "access_type": "offline", + "prompt": "consent", + }, + SupportsIncrementalAuth: true, + // analytics.readonly is required to LIST accounts and properties (the + // picker and the probe); analytics.manage.users.readonly is required to + // read the access bindings. The manage.users scope alone cannot list + // accounts (it returns 403), so both are requested. + OAuth2Scopes: []string{ + "https://www.googleapis.com/auth/analytics.readonly", + "https://www.googleapis.com/auth/analytics.manage.users.readonly", + }, + ProbeURL: "https://analyticsadmin.googleapis.com/v1alpha/accounts?pageSize=1", + NewDriver: func(_ context.Context, c *http.Client, conn *coredata.Connector, _ *log.Logger) (drivers.Driver, error) { + s, err := coredata.ConnectorSettings[coredata.GoogleAnalyticsConnectorSettings](conn) + if err != nil { + return nil, fmt.Errorf("cannot read google analytics connector settings: %w", err) + } + + if s.AccountID == "" { + return nil, fmt.Errorf("cannot create google analytics driver: account_id is required") + } + + return drivers.NewGoogleAnalyticsDriver(c, s.AccountID), nil + }, + NewNameResolver: func(ctx context.Context, c *http.Client, conn *coredata.Connector, logger *log.Logger) drivers.NameResolver { + s, err := coredata.ConnectorSettings[coredata.GoogleAnalyticsConnectorSettings](conn) + if err != nil { + logger.ErrorCtx(ctx, "cannot read google analytics connector settings", log.Error(err)) + + return nil + } + + return drivers.NewGoogleAnalyticsNameResolver(c, s.AccountID) + }, + SetOrganizationSettings: func(c *coredata.Connector, accountID string) error { + return c.SetSettings(&coredata.GoogleAnalyticsConnectorSettings{AccountID: accountID}) + }, + } +} diff --git a/pkg/connector/provider/probe.go b/pkg/connector/provider/probe.go index 20b682bd90..6a1f2f9db5 100644 --- a/pkg/connector/provider/probe.go +++ b/pkg/connector/provider/probe.go @@ -43,6 +43,8 @@ const ( crispAPIBaseURL = "https://api.crisp.chat/v1" crispTierHeader = "X-Crisp-Tier" crispTierValue = "plugin" + squareVersion = "2026-05-20" + squareMerchantProbeURL = "https://connect.squareup.com/v2/merchants/me" ) // ProbeConnection verifies that the connector credential is accepted by the @@ -632,3 +634,47 @@ func probePostHog( return fmt.Errorf("credential rejected: no posthog region accepted the connection") } + +// buildSegmentProbeURL builds the Segment users probe URL from the connector's +// stored base URL (the region-resolved host). GET /users returns 401 on a dead +// or under-scoped Public API token. +func buildSegmentProbeURL(conn *coredata.Connector) (string, error) { + s, err := coredata.ConnectorSettings[coredata.SegmentConnectorSettings](conn) + if err != nil { + return "", fmt.Errorf("cannot read segment connector settings: %w", err) + } + + if s.BaseURL == "" { + return "", fmt.Errorf("missing segment base URL") + } + + u, err := url.Parse(s.BaseURL) + if err != nil { + return "", fmt.Errorf("cannot parse segment base URL: %w", err) + } + + u.Path = "/users" + u.RawQuery = "pagination.count=1" + + return u.String(), nil +} + +// probeSquare checks a Square credential (OAuth Bearer token or Personal Access +// Token) with a GET /v2/merchants/me, sending the required Square-Version +// header. The endpoint returns 401 on a dead token and works for both OAuth and +// PAT connections, which are always scoped to a single merchant. +func probeSquare( + ctx context.Context, + httpClient *http.Client, + _ *coredata.Connector, +) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, squareMerchantProbeURL, nil) + if err != nil { + return fmt.Errorf("cannot create probe request: %w", err) + } + + req.Header.Set("Accept", "application/json") + req.Header.Set("Square-Version", squareVersion) + + return doProbeRequest(httpClient, req) +} diff --git a/pkg/connector/provider/probe_test.go b/pkg/connector/provider/probe_test.go index cca9ae9dde..3fc620187a 100644 --- a/pkg/connector/provider/probe_test.go +++ b/pkg/connector/provider/probe_test.go @@ -131,6 +131,19 @@ func TestBuildScalewayProbeURL(t *testing.T) { ) } +func TestBuildSegmentProbeURL(t *testing.T) { + t.Parallel() + + conn := &coredata.Connector{Provider: coredata.ConnectorProviderSegment} + require.NoError(t, conn.SetSettings(&coredata.SegmentConnectorSettings{ + BaseURL: "https://eu1.api.segmentapis.com", + })) + + probeURL, err := buildSegmentProbeURL(conn) + require.NoError(t, err) + assert.Equal(t, "https://eu1.api.segmentapis.com/users?pagination.count=1", probeURL) +} + func TestProbeOpenRouter(t *testing.T) { t.Parallel() diff --git a/pkg/connector/provider/segment.go b/pkg/connector/provider/segment.go new file mode 100644 index 0000000000..c6cc4e3ea4 --- /dev/null +++ b/pkg/connector/provider/segment.go @@ -0,0 +1,58 @@ +// 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" + "fmt" + "net/http" + + "go.gearno.de/kit/log" + + "go.probo.inc/probo/pkg/accessreview/drivers" + "go.probo.inc/probo/pkg/coredata" +) + +func segmentRegistration() *Registration { + return &Registration{ + Provider: coredata.ConnectorProviderSegment, + DisplayName: "Segment", + SupportsAPIKey: true, + // Segment authenticates with a Public API token as the default + // Authorization: Bearer scheme, so no APIKeyHeader. The token is bound + // to one workspace, but the workspace's region selects the API host + // (US vs EU) and is not discoverable from the token, so it is captured + // as an extra setting and resolved to a base URL (Pattern 3 + region); + // there is nothing to pick. + ExtraSettings: []ExtraSetting{ + {Key: "region", Label: "Region (US or EU)", Required: true}, + }, + BuildProbeURL: buildSegmentProbeURL, + // No NewNameResolver: the Public API exposes no read-only workspace-name + // endpoint on the token's scope, so the source keeps its generic name. + NewDriver: func(_ context.Context, c *http.Client, conn *coredata.Connector, _ *log.Logger) (drivers.Driver, error) { + s, err := coredata.ConnectorSettings[coredata.SegmentConnectorSettings](conn) + if err != nil { + return nil, fmt.Errorf("cannot read segment connector settings: %w", err) + } + + if s.BaseURL == "" { + return nil, fmt.Errorf("cannot create segment driver: base URL is required") + } + + return drivers.NewSegmentDriver(c, s.BaseURL), nil + }, + } +} diff --git a/pkg/connector/provider/square.go b/pkg/connector/provider/square.go new file mode 100644 index 0000000000..18d51ffefd --- /dev/null +++ b/pkg/connector/provider/square.go @@ -0,0 +1,53 @@ +// 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 squareRegistration() *Registration { + return &Registration{ + Provider: coredata.ConnectorProviderSquare, + DisplayName: "Square", + AuthURL: "https://connect.squareup.com/oauth2/authorize", + TokenURL: "https://connect.squareup.com/oauth2/token", + // EMPLOYEES_READ lists team members; MERCHANT_PROFILE_READ is needed + // for the merchant-name resolver and the /v2/merchants/me probe. + // Square's confidential token endpoint accepts client credentials in + // the form body (the default post-form scheme) and rejects HTTP Basic, + // so no TokenEndpointAuth override is set. + OAuth2Scopes: []string{"EMPLOYEES_READ", "MERCHANT_PROFILE_READ"}, + Probe: probeSquare, + // SupportsAPIKey enables the Personal Access Token fallback, which + // authenticates with the same Authorization: Bearer scheme as the OAuth + // token. A Square token — OAuth or PAT — is always scoped to one + // merchant, so there is nothing to pick (Pattern 3): no settings + // struct, no picker, no OAuth-callback capture. + SupportsAPIKey: true, + NewDriver: func(_ context.Context, c *http.Client, _ *coredata.Connector, _ *log.Logger) (drivers.Driver, error) { + return drivers.NewSquareDriver(c), nil + }, + NewNameResolver: func(_ context.Context, c *http.Client, _ *coredata.Connector, _ *log.Logger) drivers.NameResolver { + return drivers.NewSquareNameResolver(c) + }, + } +} diff --git a/pkg/coredata/connector_provider.go b/pkg/coredata/connector_provider.go index a9a28b1e66..b023ba94f6 100644 --- a/pkg/coredata/connector_provider.go +++ b/pkg/coredata/connector_provider.go @@ -26,58 +26,62 @@ const ( ConnectorProviderGoogleWorkspace ConnectorProvider = "GOOGLE_WORKSPACE" ConnectorProviderLinear ConnectorProvider = "LINEAR" // _ ConnectorProvider = "FIGMA" — formerly Figma; removed (no driver, no OAuth config, no usage) - ConnectorProviderOnePassword ConnectorProvider = "ONE_PASSWORD" - ConnectorProviderHubSpot ConnectorProvider = "HUBSPOT" - ConnectorProviderDocuSign ConnectorProvider = "DOCUSIGN" - ConnectorProviderNotion ConnectorProvider = "NOTION" - ConnectorProviderBrex ConnectorProvider = "BREX" - ConnectorProviderTally ConnectorProvider = "TALLY" - ConnectorProviderCloudflare ConnectorProvider = "CLOUDFLARE" - ConnectorProviderGrafana ConnectorProvider = "GRAFANA" - ConnectorProviderOpenAI ConnectorProvider = "OPENAI" - ConnectorProviderPostHog ConnectorProvider = "POSTHOG" - ConnectorProviderSentry ConnectorProvider = "SENTRY" - ConnectorProviderSigNoz ConnectorProvider = "SIGNOZ" - ConnectorProviderSupabase ConnectorProvider = "SUPABASE" - ConnectorProviderBetterStack ConnectorProvider = "BETTER_STACK" - ConnectorProviderGitHub ConnectorProvider = "GITHUB" - ConnectorProviderIntercom ConnectorProvider = "INTERCOM" - ConnectorProviderResend ConnectorProvider = "RESEND" - ConnectorProviderSendGrid ConnectorProvider = "SENDGRID" - ConnectorProviderMicrosoft365 ConnectorProvider = "MICROSOFT_365" - ConnectorProviderGitLab ConnectorProvider = "GITLAB" - ConnectorProviderBitbucket ConnectorProvider = "BITBUCKET" - ConnectorProviderHeroku ConnectorProvider = "HEROKU" - ConnectorProviderPagerDuty ConnectorProvider = "PAGERDUTY" - ConnectorProviderAsana ConnectorProvider = "ASANA" - ConnectorProviderNetlify ConnectorProvider = "NETLIFY" - ConnectorProviderClickUp ConnectorProvider = "CLICKUP" - ConnectorProviderClerk ConnectorProvider = "CLERK" - ConnectorProviderVercel ConnectorProvider = "VERCEL" - ConnectorProviderMonday ConnectorProvider = "MONDAY" - ConnectorProviderMetabase ConnectorProvider = "METABASE" - ConnectorProviderTailscale ConnectorProvider = "TAILSCALE" - ConnectorProviderAnthropic ConnectorProvider = "ANTHROPIC" - ConnectorProviderCursor ConnectorProvider = "CURSOR" - ConnectorProviderDatadog ConnectorProvider = "DATADOG" - ConnectorProviderOkta ConnectorProvider = "OKTA" - ConnectorProviderZendesk ConnectorProvider = "ZENDESK" - ConnectorProviderQovery ConnectorProvider = "QOVERY" - ConnectorProviderRender ConnectorProvider = "RENDER" - ConnectorProviderNeon ConnectorProvider = "NEON" - ConnectorProviderMercury ConnectorProvider = "MERCURY" - ConnectorProviderApollo ConnectorProvider = "APOLLO" - ConnectorProviderDeepgram ConnectorProvider = "DEEPGRAM" - ConnectorProviderClickHouse ConnectorProvider = "CLICKHOUSE" - ConnectorProviderLangfuse ConnectorProvider = "LANGFUSE" - ConnectorProviderPylon ConnectorProvider = "PYLON" - ConnectorProviderOpenRouter ConnectorProvider = "OPENROUTER" - ConnectorProviderIncidentIO ConnectorProvider = "INCIDENT_IO" - ConnectorProviderBrevo ConnectorProvider = "BREVO" - ConnectorProviderScaleway ConnectorProvider = "SCALEWAY" - ConnectorProviderYousign ConnectorProvider = "YOUSIGN" - ConnectorProviderRailway ConnectorProvider = "RAILWAY" - ConnectorProviderCrisp ConnectorProvider = "CRISP" + ConnectorProviderOnePassword ConnectorProvider = "ONE_PASSWORD" + ConnectorProviderHubSpot ConnectorProvider = "HUBSPOT" + ConnectorProviderDocuSign ConnectorProvider = "DOCUSIGN" + ConnectorProviderNotion ConnectorProvider = "NOTION" + ConnectorProviderBrex ConnectorProvider = "BREX" + ConnectorProviderTally ConnectorProvider = "TALLY" + ConnectorProviderCloudflare ConnectorProvider = "CLOUDFLARE" + ConnectorProviderGrafana ConnectorProvider = "GRAFANA" + ConnectorProviderOpenAI ConnectorProvider = "OPENAI" + ConnectorProviderPostHog ConnectorProvider = "POSTHOG" + ConnectorProviderSentry ConnectorProvider = "SENTRY" + ConnectorProviderSigNoz ConnectorProvider = "SIGNOZ" + ConnectorProviderSupabase ConnectorProvider = "SUPABASE" + ConnectorProviderBetterStack ConnectorProvider = "BETTER_STACK" + ConnectorProviderGitHub ConnectorProvider = "GITHUB" + ConnectorProviderIntercom ConnectorProvider = "INTERCOM" + ConnectorProviderResend ConnectorProvider = "RESEND" + ConnectorProviderSendGrid ConnectorProvider = "SENDGRID" + ConnectorProviderMicrosoft365 ConnectorProvider = "MICROSOFT_365" + ConnectorProviderGitLab ConnectorProvider = "GITLAB" + ConnectorProviderBitbucket ConnectorProvider = "BITBUCKET" + ConnectorProviderHeroku ConnectorProvider = "HEROKU" + ConnectorProviderPagerDuty ConnectorProvider = "PAGERDUTY" + ConnectorProviderAsana ConnectorProvider = "ASANA" + ConnectorProviderNetlify ConnectorProvider = "NETLIFY" + ConnectorProviderClickUp ConnectorProvider = "CLICKUP" + ConnectorProviderClerk ConnectorProvider = "CLERK" + ConnectorProviderVercel ConnectorProvider = "VERCEL" + ConnectorProviderMonday ConnectorProvider = "MONDAY" + ConnectorProviderMetabase ConnectorProvider = "METABASE" + ConnectorProviderTailscale ConnectorProvider = "TAILSCALE" + ConnectorProviderAnthropic ConnectorProvider = "ANTHROPIC" + ConnectorProviderCursor ConnectorProvider = "CURSOR" + ConnectorProviderDatadog ConnectorProvider = "DATADOG" + ConnectorProviderOkta ConnectorProvider = "OKTA" + ConnectorProviderZendesk ConnectorProvider = "ZENDESK" + ConnectorProviderQovery ConnectorProvider = "QOVERY" + ConnectorProviderRender ConnectorProvider = "RENDER" + ConnectorProviderNeon ConnectorProvider = "NEON" + ConnectorProviderMercury ConnectorProvider = "MERCURY" + ConnectorProviderApollo ConnectorProvider = "APOLLO" + ConnectorProviderDeepgram ConnectorProvider = "DEEPGRAM" + ConnectorProviderClickHouse ConnectorProvider = "CLICKHOUSE" + ConnectorProviderLangfuse ConnectorProvider = "LANGFUSE" + ConnectorProviderPylon ConnectorProvider = "PYLON" + ConnectorProviderOpenRouter ConnectorProvider = "OPENROUTER" + ConnectorProviderIncidentIO ConnectorProvider = "INCIDENT_IO" + ConnectorProviderBrevo ConnectorProvider = "BREVO" + ConnectorProviderScaleway ConnectorProvider = "SCALEWAY" + ConnectorProviderYousign ConnectorProvider = "YOUSIGN" + ConnectorProviderRailway ConnectorProvider = "RAILWAY" + ConnectorProviderCrisp ConnectorProvider = "CRISP" + ConnectorProviderDotfile ConnectorProvider = "DOTFILE" + ConnectorProviderSegment ConnectorProvider = "SEGMENT" + ConnectorProviderSquare ConnectorProvider = "SQUARE" + ConnectorProviderGoogleAnalytics ConnectorProvider = "GOOGLE_ANALYTICS" ) var ( @@ -143,6 +147,10 @@ func ConnectorProviders() []ConnectorProvider { ConnectorProviderYousign, ConnectorProviderRailway, ConnectorProviderCrisp, + ConnectorProviderDotfile, + ConnectorProviderSegment, + ConnectorProviderSquare, + ConnectorProviderGoogleAnalytics, } } @@ -203,7 +211,11 @@ func (v ConnectorProvider) IsValid() bool { ConnectorProviderScaleway, ConnectorProviderYousign, ConnectorProviderRailway, - ConnectorProviderCrisp: + ConnectorProviderCrisp, + ConnectorProviderDotfile, + ConnectorProviderSegment, + ConnectorProviderSquare, + ConnectorProviderGoogleAnalytics: return true } diff --git a/pkg/coredata/connector_settings.go b/pkg/coredata/connector_settings.go index 6d9e7bb2a8..7067acb2ea 100644 --- a/pkg/coredata/connector_settings.go +++ b/pkg/coredata/connector_settings.go @@ -188,6 +188,25 @@ type ( CrispConnectorSettings struct { WebsiteID string `json:"website_id"` } + + // SegmentConnectorSettings stores the Twilio Segment Public API base URL. + // A Public API token is bound to one workspace, but the workspace's region + // is not discoverable from the token and selects the API host + // (api.segmentapis.com for US, eu1.api.segmentapis.com for EU). The region + // the customer picks is resolved to this base URL up front, so the driver + // and probe read a single host with no region logic. + SegmentConnectorSettings struct { + BaseURL string `json:"base_url"` + } + + // GoogleAnalyticsConnectorSettings stores the selected GA4 account ID + // (the numeric portion of the `accounts/{account}` resource name). A + // Google OAuth token can reach many GA4 accounts, so the reviewed account + // is picked after authorization; the driver then lists the account's + // access bindings plus the bindings of every property beneath it. + GoogleAnalyticsConnectorSettings struct { + AccountID string `json:"account_id"` + } ) // GrantType returns the OAuth2 grant type recorded on the connector's diff --git a/pkg/coredata/migrations/20260711T550516Z.sql b/pkg/coredata/migrations/20260711T550516Z.sql new file mode 100644 index 0000000000..52a966f371 --- /dev/null +++ b/pkg/coredata/migrations/20260711T550516Z.sql @@ -0,0 +1,18 @@ +-- 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 'DOTFILE'; +ALTER TYPE connector_provider ADD VALUE IF NOT EXISTS 'SEGMENT'; +ALTER TYPE connector_provider ADD VALUE IF NOT EXISTS 'SQUARE'; +ALTER TYPE connector_provider ADD VALUE IF NOT EXISTS 'GOOGLE_ANALYTICS'; diff --git a/pkg/server/api/console/v1/access_source_provider_config.go b/pkg/server/api/console/v1/access_source_provider_config.go index 599b2d192f..1510657d96 100644 --- a/pkg/server/api/console/v1/access_source_provider_config.go +++ b/pkg/server/api/console/v1/access_source_provider_config.go @@ -60,6 +60,14 @@ var providerOrgConfigs = map[coredata.ConnectorProvider]providerOrgConfig{ }, NeedsPicker: true, }, + coredata.ConnectorProviderGoogleAnalytics: { + ListOrgs: drivers.ListGoogleAnalyticsOrganizations, + SelectedSlug: func(c *coredata.Connector) string { + s, _ := coredata.ConnectorSettings[coredata.GoogleAnalyticsConnectorSettings](c) + return s.AccountID + }, + NeedsPicker: true, + }, coredata.ConnectorProviderGitLab: { ListOrgs: drivers.ListGitLabOrganizations, SelectedSlug: func(c *coredata.Connector) string { diff --git a/pkg/server/api/console/v1/connector_settings.go b/pkg/server/api/console/v1/connector_settings.go index 941d82169f..e975722e51 100644 --- a/pkg/server/api/console/v1/connector_settings.go +++ b/pkg/server/api/console/v1/connector_settings.go @@ -322,6 +322,27 @@ func apiKeyConnectorSettings(input types.CreateAPIKeyConnectorInput) (json.RawMe } return json.Marshal(&coredata.ScalewayConnectorSettings{OrganizationID: *input.ScalewayOrganizationID}) + case coredata.ConnectorProviderSegment: + region := "" + if input.SegmentRegion != nil { + region = strings.ToUpper(strings.TrimSpace(*input.SegmentRegion)) + } + + // The region selects the API host and is not discoverable from the + // token; resolve it to a base URL against a fixed allow-list so an + // unexpected value cannot reach the driver. + var baseURL string + + switch region { + case "US": + baseURL = "https://api.segmentapis.com" + case "EU": + baseURL = "https://eu1.api.segmentapis.com" + default: + return nil, fmt.Errorf("cannot create segment connector: segmentRegion must be US or EU") + } + + return json.Marshal(&coredata.SegmentConnectorSettings{BaseURL: baseURL}) case coredata.ConnectorProviderCrisp: websiteID := "" if input.CrispWebsiteID != nil { diff --git a/pkg/server/api/console/v1/graphql/connector.graphql b/pkg/server/api/console/v1/graphql/connector.graphql index cdf4c16b30..d9778e5b18 100644 --- a/pkg/server/api/console/v1/graphql/connector.graphql +++ b/pkg/server/api/console/v1/graphql/connector.graphql @@ -99,6 +99,15 @@ enum ConnectorProvider RAILWAY @goEnum(value: "go.probo.inc/probo/pkg/coredata.ConnectorProviderRailway") CRISP @goEnum(value: "go.probo.inc/probo/pkg/coredata.ConnectorProviderCrisp") + DOTFILE + @goEnum(value: "go.probo.inc/probo/pkg/coredata.ConnectorProviderDotfile") + SEGMENT + @goEnum(value: "go.probo.inc/probo/pkg/coredata.ConnectorProviderSegment") + SQUARE @goEnum(value: "go.probo.inc/probo/pkg/coredata.ConnectorProviderSquare") + GOOGLE_ANALYTICS + @goEnum( + value: "go.probo.inc/probo/pkg/coredata.ConnectorProviderGoogleAnalytics" + ) } type ConnectorProviderInfo { @@ -197,6 +206,7 @@ input CreateAPIKeyConnectorInput { langfuseBaseUrl: String scalewayOrganizationId: String crispWebsiteId: String + segmentRegion: String } type CreateAPIKeyConnectorPayload { From 3785d0f0815df6f31ec5845dd3d286017f5dc0ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Sibiril?= <81782+aureliensibiril@users.noreply.github.com> Date: Sat, 11 Jul 2026 23:15:12 +0200 Subject: [PATCH 02/10] Add Google Analytics, Dotfile, Segment and Square connector logos MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Aurélien Sibiril <81782+aureliensibiril@users.noreply.github.com> --- .../ui/src/Atoms/ThirdParties/Dotfile.tsx | 21 +++++++++++++ .../Atoms/ThirdParties/GoogleAnalytics.tsx | 12 ++++++++ .../ui/src/Atoms/ThirdParties/Segment.tsx | 30 +++++++++++++++++++ packages/ui/src/Atoms/ThirdParties/Square.tsx | 12 ++++++++ .../src/Atoms/ThirdParties/ThirdPartyLogo.tsx | 8 +++++ packages/ui/src/Atoms/ThirdParties/index.ts | 4 +++ 6 files changed, 87 insertions(+) create mode 100644 packages/ui/src/Atoms/ThirdParties/Dotfile.tsx create mode 100644 packages/ui/src/Atoms/ThirdParties/GoogleAnalytics.tsx create mode 100644 packages/ui/src/Atoms/ThirdParties/Segment.tsx create mode 100644 packages/ui/src/Atoms/ThirdParties/Square.tsx diff --git a/packages/ui/src/Atoms/ThirdParties/Dotfile.tsx b/packages/ui/src/Atoms/ThirdParties/Dotfile.tsx new file mode 100644 index 0000000000..2c8078b513 --- /dev/null +++ b/packages/ui/src/Atoms/ThirdParties/Dotfile.tsx @@ -0,0 +1,21 @@ +import type { ComponentProps } from "react"; + +export function Dotfile(props: ComponentProps<"svg">) { + return ( + + + + + ); +} diff --git a/packages/ui/src/Atoms/ThirdParties/GoogleAnalytics.tsx b/packages/ui/src/Atoms/ThirdParties/GoogleAnalytics.tsx new file mode 100644 index 0000000000..c4a010f15b --- /dev/null +++ b/packages/ui/src/Atoms/ThirdParties/GoogleAnalytics.tsx @@ -0,0 +1,12 @@ +import type { ComponentProps } from "react"; + +export function GoogleAnalytics(props: ComponentProps<"svg">) { + return ( + + + + ); +} diff --git a/packages/ui/src/Atoms/ThirdParties/Segment.tsx b/packages/ui/src/Atoms/ThirdParties/Segment.tsx new file mode 100644 index 0000000000..2a91828de7 --- /dev/null +++ b/packages/ui/src/Atoms/ThirdParties/Segment.tsx @@ -0,0 +1,30 @@ +import type { ComponentProps } from "react"; + +export function Segment(props: ComponentProps<"svg">) { + return ( + + + + + + + + + + + + + ); +} diff --git a/packages/ui/src/Atoms/ThirdParties/Square.tsx b/packages/ui/src/Atoms/ThirdParties/Square.tsx new file mode 100644 index 0000000000..b20354020f --- /dev/null +++ b/packages/ui/src/Atoms/ThirdParties/Square.tsx @@ -0,0 +1,12 @@ +import type { ComponentProps } from "react"; + +export function Square(props: ComponentProps<"svg">) { + return ( + + + + ); +} diff --git a/packages/ui/src/Atoms/ThirdParties/ThirdPartyLogo.tsx b/packages/ui/src/Atoms/ThirdParties/ThirdPartyLogo.tsx index e39ce7448e..d724fa9982 100644 --- a/packages/ui/src/Atoms/ThirdParties/ThirdPartyLogo.tsx +++ b/packages/ui/src/Atoms/ThirdParties/ThirdPartyLogo.tsx @@ -30,10 +30,12 @@ import { Cursor } from "./Cursor"; import { Datadog } from "./Datadog"; import { Deepgram } from "./Deepgram"; import { DocuSign } from "./DocuSign"; +import { Dotfile } from "./Dotfile"; import { Figma } from "./Figma"; import { GitHub } from "./GitHub"; import { GitLab } from "./GitLab"; import { Google } from "./Google"; +import { GoogleAnalytics } from "./GoogleAnalytics"; import { Grafana } from "./Grafana"; import { Heroku } from "./Heroku"; import { HubSpot } from "./HubSpot"; @@ -60,10 +62,12 @@ import { Railway } from "./Railway"; import { Render } from "./Render"; import { Resend } from "./Resend"; import { Scaleway } from "./Scaleway"; +import { Segment } from "./Segment"; import { SendGrid } from "./SendGrid"; import { Sentry } from "./Sentry"; import { SigNoz } from "./SigNoz"; import { Slack } from "./Slack"; +import { Square } from "./Square"; import { Supabase } from "./Supabase"; import { Tailscale } from "./Tailscale"; import { Tally } from "./Tally"; @@ -88,10 +92,12 @@ const thirdParties: Record>> = { DATADOG: Datadog, DEEPGRAM: Deepgram, DOCUSIGN: DocuSign, + DOTFILE: Dotfile, FIGMA: Figma, GITHUB: GitHub, GITLAB: GitLab, GOOGLE: Google, + GOOGLE_ANALYTICS: GoogleAnalytics, GOOGLE_WORKSPACE: Google, GRAFANA: Grafana, HEROKU: Heroku, @@ -121,10 +127,12 @@ const thirdParties: Record>> = { RENDER: Render, RESEND: Resend, SCALEWAY: Scaleway, + SEGMENT: Segment, SENDGRID: SendGrid, SENTRY: Sentry, SIGNOZ: SigNoz, SLACK: Slack, + SQUARE: Square, SUPABASE: Supabase, TAILSCALE: Tailscale, TALLY: Tally, diff --git a/packages/ui/src/Atoms/ThirdParties/index.ts b/packages/ui/src/Atoms/ThirdParties/index.ts index fb6939de2e..b84e556e2f 100644 --- a/packages/ui/src/Atoms/ThirdParties/index.ts +++ b/packages/ui/src/Atoms/ThirdParties/index.ts @@ -14,10 +14,12 @@ export { Cursor } from "./Cursor"; export { Datadog } from "./Datadog"; export { Deepgram } from "./Deepgram"; export { DocuSign } from "./DocuSign"; +export { Dotfile } from "./Dotfile"; export { Figma } from "./Figma"; export { GitHub } from "./GitHub"; export { GitLab } from "./GitLab"; export { Google } from "./Google"; +export { GoogleAnalytics } from "./GoogleAnalytics"; export { Grafana } from "./Grafana"; export { Heroku } from "./Heroku"; export { HubSpot } from "./HubSpot"; @@ -44,10 +46,12 @@ export { Railway } from "./Railway"; export { Render } from "./Render"; export { Resend } from "./Resend"; export { Scaleway } from "./Scaleway"; +export { Segment } from "./Segment"; export { SendGrid } from "./SendGrid"; export { Sentry } from "./Sentry"; export { SigNoz } from "./SigNoz"; export { Slack } from "./Slack"; +export { Square } from "./Square"; export { Supabase } from "./Supabase"; export { Tally } from "./Tally"; export { Tailscale } from "./Tailscale"; From c774c35c5a05de861a0a35e9443b739887ab1740 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Sibiril?= <81782+aureliensibiril@users.noreply.github.com> Date: Sun, 12 Jul 2026 11:24:01 +0200 Subject: [PATCH 03/10] Escape dynamic path segments in the Google Analytics and Segment drivers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The GA4 driver passed the account and property IDs to url.JoinPath as raw segments, and the Segment driver built its per-user endpoint by concatenating the user ID into url.URL.Path — both bypass the url.PathEscape rule that every sibling driver (and the matching name resolvers) already follow. The IDs are numeric today so there is no behaviour change, but this keeps the drivers consistent and safe if a provider ever returns a segment with a reserved character. Signed-off-by: Aurélien Sibiril <81782+aureliensibiril@users.noreply.github.com> --- pkg/accessreview/drivers/google_analytics.go | 4 ++-- pkg/accessreview/drivers/segment.go | 9 +++++---- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/pkg/accessreview/drivers/google_analytics.go b/pkg/accessreview/drivers/google_analytics.go index e2ff595508..6443db12ab 100644 --- a/pkg/accessreview/drivers/google_analytics.go +++ b/pkg/accessreview/drivers/google_analytics.go @@ -94,7 +94,7 @@ func (d *GoogleAnalyticsDriver) ListAccounts(ctx context.Context) ([]AccountReco members := make(map[string]*googleAnalyticsMember) // Account-level bindings. - if err := d.collectBindings(ctx, members, "v1alpha", "accounts", d.accountID, "accessBindings"); err != nil { + if err := d.collectBindings(ctx, members, "v1alpha", "accounts", url.PathEscape(d.accountID), "accessBindings"); err != nil { return nil, err } @@ -105,7 +105,7 @@ func (d *GoogleAnalyticsDriver) ListAccounts(ctx context.Context) ([]AccountReco } for _, propertyID := range propertyIDs { - if err := d.collectBindings(ctx, members, "v1alpha", "properties", propertyID, "accessBindings"); err != nil { + if err := d.collectBindings(ctx, members, "v1alpha", "properties", url.PathEscape(propertyID), "accessBindings"); err != nil { return nil, err } } diff --git a/pkg/accessreview/drivers/segment.go b/pkg/accessreview/drivers/segment.go index 39c1b7841e..e6ab490fd5 100644 --- a/pkg/accessreview/drivers/segment.go +++ b/pkg/accessreview/drivers/segment.go @@ -204,12 +204,13 @@ func (d *SegmentDriver) listUsers(ctx context.Context, base *url.URL) ([]segment } func (d *SegmentDriver) userPermissions(ctx context.Context, base *url.URL, userID string) ([]segmentPermission, error) { - endpoint := *base - // Path holds the decoded value; endpoint.String() escapes the id once. - endpoint.Path = "/users/" + userID + endpoint, err := url.JoinPath(base.String(), "users", url.PathEscape(userID)) + if err != nil { + return nil, fmt.Errorf("cannot build segment user URL: %w", err) + } var resp segmentUserResponse - if err := d.getJSON(ctx, endpoint.String(), &resp); err != nil { + if err := d.getJSON(ctx, endpoint, &resp); err != nil { return nil, err } From c77173a15179117a3611910830de8d9f1f16aeb0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Sibiril?= <81782+aureliensibiril@users.noreply.github.com> Date: Sun, 12 Jul 2026 11:24:01 +0200 Subject: [PATCH 04/10] Strip the Dotfile API key header when recording cassettes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shared VCR BeforeSave hook scrubs every other header-auth provider's key (X-Api-Key, Api-Key, Signoz-Api-Key, X-Auth-Token) but was not updated for Dotfile, so re-recording testdata/dotfile.yaml with a real X-DOTFILE-API-KEY would persist the key into the committed cassette. Delete the canonicalized X-Dotfile-Api-Key header alongside the others. Signed-off-by: Aurélien Sibiril <81782+aureliensibiril@users.noreply.github.com> --- pkg/accessreview/drivers/vcr_test.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pkg/accessreview/drivers/vcr_test.go b/pkg/accessreview/drivers/vcr_test.go index 805d3d478d..76d48e3160 100644 --- a/pkg/accessreview/drivers/vcr_test.go +++ b/pkg/accessreview/drivers/vcr_test.go @@ -60,6 +60,9 @@ func newRecorder(t *testing.T, cassettePath string, envVar string) *recorder.Rec i.Request.Headers.Del("Api-Key") // Scaleway authenticates with the secret key in X-Auth-Token. i.Request.Headers.Del("X-Auth-Token") + // Dotfile authenticates with the key in X-DOTFILE-API-KEY + // (canonicalized to X-Dotfile-Api-Key). + i.Request.Headers.Del("X-Dotfile-Api-Key") return nil }, recorder.BeforeSaveHook), From 774aae6548198dde4394ed60d7acb9e7ad00db65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Sibiril?= <81782+aureliensibiril@users.noreply.github.com> Date: Sun, 12 Jul 2026 11:24:01 +0200 Subject: [PATCH 05/10] Test the Square and Google Analytics name resolvers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cover the 2xx business/display-name path, the terminal non-2xx branches (401/403/404/500 keep the generic source name), the Square-Version header, and Google Analytics' empty-account-id short-circuit, matching the existing resolver tests. Signed-off-by: Aurélien Sibiril <81782+aureliensibiril@users.noreply.github.com> --- .../drivers/name_resolver_test.go | 134 ++++++++++++++++++ 1 file changed, 134 insertions(+) diff --git a/pkg/accessreview/drivers/name_resolver_test.go b/pkg/accessreview/drivers/name_resolver_test.go index 84100a0a6d..d93ab64a02 100644 --- a/pkg/accessreview/drivers/name_resolver_test.go +++ b/pkg/accessreview/drivers/name_resolver_test.go @@ -728,3 +728,137 @@ type roundTripperFunc func(*http.Request) (*http.Response, error) func (f roundTripperFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } + +func TestSquareNameResolver(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + status int + body string + want string + }{ + { + name: "200 returns business name", + status: http.StatusOK, + body: `{"merchant":{"business_name":"Acme Coffee"}}`, + want: "Acme Coffee", + }, + { + name: "401 is terminal (no name)", + status: http.StatusUnauthorized, + body: `{"errors":[{"code":"UNAUTHORIZED"}]}`, + want: "", + }, + { + name: "403 is terminal (no name)", + status: http.StatusForbidden, + body: `{"errors":[{"code":"FORBIDDEN"}]}`, + want: "", + }, + { + name: "500 is terminal (no name)", + status: http.StatusInternalServerError, + body: `{"errors":[{"code":"INTERNAL_SERVER_ERROR"}]}`, + want: "", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method) + assert.Equal(t, "/v2/merchants/me", r.URL.Path) + assert.Equal(t, squareAPIVersion, r.Header.Get("Square-Version")) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(tc.status) + _, _ = w.Write([]byte(tc.body)) + })) + defer srv.Close() + + client := &http.Client{Transport: &hostRewriter{target: srv.URL}} + + got, err := NewSquareNameResolver(client).ResolveInstanceName(context.Background()) + require.NoError(t, err) + assert.Equal(t, tc.want, got) + }) + } +} + +func TestGoogleAnalyticsNameResolver(t *testing.T) { + t.Parallel() + + t.Run("empty account id returns nothing without HTTP call", func(t *testing.T) { + t.Parallel() + + client := &http.Client{Transport: roundTripperFunc(func(*http.Request) (*http.Response, error) { + t.Fatalf("resolver should not make an HTTP call for an empty account id") + return nil, nil + })} + + got, err := NewGoogleAnalyticsNameResolver(client, "").ResolveInstanceName(context.Background()) + require.NoError(t, err) + assert.Empty(t, got) + }) + + cases := []struct { + name string + status int + body string + want string + }{ + { + name: "200 returns display name", + status: http.StatusOK, + body: `{"displayName":"Acme Analytics"}`, + want: "Acme Analytics", + }, + { + name: "401 is terminal (no name)", + status: http.StatusUnauthorized, + body: `{"error":{"code":401}}`, + want: "", + }, + { + name: "403 is terminal (no name)", + status: http.StatusForbidden, + body: `{"error":{"code":403}}`, + want: "", + }, + { + name: "404 is terminal (no name)", + status: http.StatusNotFound, + body: `{"error":{"code":404}}`, + want: "", + }, + { + name: "500 is terminal (no name)", + status: http.StatusInternalServerError, + body: `{"error":{"code":500}}`, + want: "", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method) + assert.Equal(t, "/v1alpha/accounts/123456", r.URL.Path) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(tc.status) + _, _ = w.Write([]byte(tc.body)) + })) + defer srv.Close() + + client := &http.Client{Transport: &hostRewriter{target: srv.URL}} + + got, err := NewGoogleAnalyticsNameResolver(client, "123456").ResolveInstanceName(context.Background()) + require.NoError(t, err) + assert.Equal(t, tc.want, got) + }) + } +} From 43ce62a55b452b804792c7297789e40e84be35e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Sibiril?= <81782+aureliensibiril@users.noreply.github.com> Date: Sun, 12 Jul 2026 19:42:04 +0200 Subject: [PATCH 06/10] List Google Analytics subproperties in access reviews MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit listProperties filtered properties with parent:accounts/{id}, which returns only properties whose direct parent is the account and silently drops subproperties and roll-up properties (parented to another property). A member holding a binding only on such a subproperty was omitted from the review. Switch to the ancestor:accounts/{id} filter, which walks the whole account hierarchy and is a strict superset, so no property is lost. Signed-off-by: Aurélien Sibiril <81782+aureliensibiril@users.noreply.github.com> --- pkg/accessreview/drivers/google_analytics.go | 9 ++++++--- pkg/accessreview/drivers/testdata/google_analytics.yaml | 4 ++-- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/pkg/accessreview/drivers/google_analytics.go b/pkg/accessreview/drivers/google_analytics.go index 6443db12ab..086bf36898 100644 --- a/pkg/accessreview/drivers/google_analytics.go +++ b/pkg/accessreview/drivers/google_analytics.go @@ -143,13 +143,16 @@ func (d *GoogleAnalyticsDriver) collectBindings(ctx context.Context, members map return fmt.Errorf("cannot list all google analytics access bindings: %w", ErrPaginationLimitReached) } -// listProperties returns the numeric IDs of every property directly beneath the -// account. +// listProperties returns the numeric IDs of every property under the account, +// including subproperties and roll-up properties. The ancestor filter walks the +// whole account hierarchy (parent: would return only properties whose direct +// parent is the account, silently dropping subproperties parented to another +// property, and with them any subproperty-only members). func (d *GoogleAnalyticsDriver) listProperties(ctx context.Context) ([]string, error) { var propertyIDs []string pageToken := "" - filter := url.Values{"filter": {"parent:accounts/" + d.accountID}} + filter := url.Values{"filter": {"ancestor:accounts/" + d.accountID}} for range maxPaginationPages { endpoint, err := googleAnalyticsURL(pageToken, filter, "v1alpha", "properties") diff --git a/pkg/accessreview/drivers/testdata/google_analytics.yaml b/pkg/accessreview/drivers/testdata/google_analytics.yaml index a375111d77..744e9fd326 100644 --- a/pkg/accessreview/drivers/testdata/google_analytics.yaml +++ b/pkg/accessreview/drivers/testdata/google_analytics.yaml @@ -45,13 +45,13 @@ interactions: host: analyticsadmin.googleapis.com form: filter: - - parent:accounts/123456 + - ancestor:accounts/123456 pageSize: - "200" headers: Accept: - application/json - url: https://analyticsadmin.googleapis.com/v1alpha/properties?filter=parent%3Aaccounts%2F123456&pageSize=200 + url: https://analyticsadmin.googleapis.com/v1alpha/properties?filter=ancestor%3Aaccounts%2F123456&pageSize=200 method: GET response: proto: HTTP/2.0 From 7b6f7f9e1eb9bb9534838ec987c602424dd499f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Sibiril?= <81782+aureliensibiril@users.noreply.github.com> Date: Sun, 12 Jul 2026 19:42:04 +0200 Subject: [PATCH 07/10] Leave Segment members' active status unknown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Segment's /users API exposes no active/suspended field, so reporting every confirmed member as Active=true fabricated a status the source never provides, contrary to the AccountRecord contract (nil = no explicit signal). Leave Active nil for confirmed members; pending invites keep Active=false, which is a real signal from /invites. Signed-off-by: Aurélien Sibiril <81782+aureliensibiril@users.noreply.github.com> --- pkg/accessreview/drivers/segment.go | 6 ++++-- pkg/accessreview/drivers/segment_test.go | 8 ++++---- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/pkg/accessreview/drivers/segment.go b/pkg/accessreview/drivers/segment.go index e6ab490fd5..0634023c9d 100644 --- a/pkg/accessreview/drivers/segment.go +++ b/pkg/accessreview/drivers/segment.go @@ -123,13 +123,15 @@ func (d *SegmentDriver) ListAccounts(ctx context.Context) ([]AccountRecord, erro } roles, isAdmin := segmentRolesAndAdmin(perms) - active := true + // Segment's user API exposes no active/suspended status field, so + // leave Active nil (unknown) rather than fabricate a value, per the + // AccountRecord contract. records = append(records, AccountRecord{ Email: email, FullName: segmentFullName(u.Name, email), Roles: roles, - Active: &active, + Active: nil, IsAdmin: isAdmin, MFAStatus: coredata.MFAStatusUnknown, AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown, diff --git a/pkg/accessreview/drivers/segment_test.go b/pkg/accessreview/drivers/segment_test.go index ad958c8f62..d3f8eb6f1a 100644 --- a/pkg/accessreview/drivers/segment_test.go +++ b/pkg/accessreview/drivers/segment_test.go @@ -41,8 +41,9 @@ func TestSegmentDriver(t *testing.T) { assert.Equal(t, "papi@example.com", owner.FullName) assert.True(t, owner.IsAdmin) assert.Equal(t, []string{"Workspace Owner"}, owner.Roles) - require.NotNil(t, owner.Active) - assert.True(t, *owner.Active) + // Segment exposes no active/suspended status, so confirmed members carry + // no Active signal (nil), unlike pending invites below. + assert.Nil(t, owner.Active) // Second user: named; resource-scoped read-only role ⇒ not admin. member := records[1] @@ -50,8 +51,7 @@ func TestSegmentDriver(t *testing.T) { assert.Equal(t, "Sloth", member.FullName) assert.False(t, member.IsAdmin) assert.Equal(t, []string{"Source Read-only"}, member.Roles) - require.NotNil(t, member.Active) - assert.True(t, *member.Active) + assert.Nil(t, member.Active) // Pending invite: inactive, no roles, keyed by email. invite := records[2] From 8c51a19f4d743739b579884e48caf9c8a1d89f58 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Sibiril?= <81782+aureliensibiril@users.noreply.github.com> Date: Sun, 12 Jul 2026 19:42:04 +0200 Subject: [PATCH 08/10] Check status before decoding Google Analytics accounts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ListGoogleAnalyticsOrganizations decoded the response body into the success struct before inspecting the HTTP status, unlike every other lister in the file. Check the status first so a non-2xx no longer wastes a decode against an error body and the ordering matches the sibling functions. Signed-off-by: Aurélien Sibiril <81782+aureliensibiril@users.noreply.github.com> --- pkg/accessreview/drivers/organizations.go | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/pkg/accessreview/drivers/organizations.go b/pkg/accessreview/drivers/organizations.go index 5498aa4b6f..adea995965 100644 --- a/pkg/accessreview/drivers/organizations.go +++ b/pkg/accessreview/drivers/organizations.go @@ -502,6 +502,12 @@ func ListGoogleAnalyticsOrganizations(ctx context.Context, httpClient *http.Clie return nil, fmt.Errorf("cannot fetch google analytics accounts: %w", err) } + if resp.StatusCode != http.StatusOK { + _ = resp.Body.Close() + + return nil, fmt.Errorf("cannot fetch google analytics accounts: unexpected status %d", resp.StatusCode) + } + var out struct { Accounts []struct { Name string `json:"name"` @@ -511,13 +517,8 @@ func ListGoogleAnalyticsOrganizations(ctx context.Context, httpClient *http.Clie } decodeErr := json.NewDecoder(resp.Body).Decode(&out) - status := resp.StatusCode _ = resp.Body.Close() - if status != http.StatusOK { - return nil, fmt.Errorf("cannot fetch google analytics accounts: unexpected status %d", status) - } - if decodeErr != nil { return nil, fmt.Errorf("cannot decode google analytics accounts response: %w", decodeErr) } From 3f3d22844d5d577e4bba6c7c1d6ef1a1e1df9e60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Sibiril?= <81782+aureliensibiril@users.noreply.github.com> Date: Sun, 12 Jul 2026 19:54:25 +0200 Subject: [PATCH 09/10] Retry transient failures in the Dotfile driver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Square, Segment and Google Analytics drivers all wrap their transport with retryRoundTripper for flaky 5xx responses, but the Dotfile driver used the client directly. Wrap it the same way so all four connectors handle transient upstream failures consistently. Signed-off-by: Aurélien Sibiril <81782+aureliensibiril@users.noreply.github.com> --- pkg/accessreview/drivers/dotfile.go | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/pkg/accessreview/drivers/dotfile.go b/pkg/accessreview/drivers/dotfile.go index d1f2554b4b..9272166b5d 100644 --- a/pkg/accessreview/drivers/dotfile.go +++ b/pkg/accessreview/drivers/dotfile.go @@ -65,7 +65,14 @@ type dotfileUsersResponse struct { } func NewDotfileDriver(httpClient *http.Client) *DotfileDriver { - return &DotfileDriver{httpClient: httpClient} + return &DotfileDriver{ + httpClient: &http.Client{ + Transport: &retryRoundTripper{ + next: httpClient.Transport, + maxRetries: 3, + }, + }, + } } func (d *DotfileDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error) { From 04459e71a69993b123ea3419c9faab40f9761c0e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Sibiril?= <81782+aureliensibiril@users.noreply.github.com> Date: Sun, 12 Jul 2026 19:54:25 +0200 Subject: [PATCH 10/10] Explain why the Google Analytics driver keys on email MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ExternalID is normally a stable provider-side ID, not an email. GA4 access bindings identify a user only by email — no per-user ID and no display name are exposed — so email is the only stable key available. Document that on googleAnalyticsRecords so the choice reads as deliberate. Signed-off-by: Aurélien Sibiril <81782+aureliensibiril@users.noreply.github.com> --- pkg/accessreview/drivers/google_analytics.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/pkg/accessreview/drivers/google_analytics.go b/pkg/accessreview/drivers/google_analytics.go index 086bf36898..6d7c555da7 100644 --- a/pkg/accessreview/drivers/google_analytics.go +++ b/pkg/accessreview/drivers/google_analytics.go @@ -270,7 +270,9 @@ func addGoogleAnalyticsBinding(members map[string]*googleAnalyticsMember, user s } // googleAnalyticsRecords turns the merged member map into a deterministically -// ordered slice of AccountRecords. Active is left nil: GA4 access bindings +// ordered slice of AccountRecords. GA4 access bindings identify a user only by +// email — there is no stable per-user ID and no display name exposed — so the +// email is used as both ExternalID and FullName. Active is left nil: bindings // carry no account-status signal. func googleAnalyticsRecords(members map[string]*googleAnalyticsMember) []AccountRecord { emails := make([]string, 0, len(members))