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/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"; diff --git a/pkg/accessreview/drivers/dotfile.go b/pkg/accessreview/drivers/dotfile.go new file mode 100644 index 0000000000..9272166b5d --- /dev/null +++ b/pkg/accessreview/drivers/dotfile.go @@ -0,0 +1,193 @@ +// 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: &http.Client{ + Transport: &retryRoundTripper{ + next: httpClient.Transport, + maxRetries: 3, + }, + }, + } +} + +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..6d7c555da7 --- /dev/null +++ b/pkg/accessreview/drivers/google_analytics.go @@ -0,0 +1,310 @@ +// 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", url.PathEscape(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", url.PathEscape(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 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": {"ancestor: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. 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)) + 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/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) + }) + } +} diff --git a/pkg/accessreview/drivers/organizations.go b/pkg/accessreview/drivers/organizations.go index edb75826e8..adea995965 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,78 @@ 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) + } + + 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"` + DisplayName string `json:"displayName"` + } `json:"accounts"` + NextPageToken string `json:"nextPageToken"` + } + + decodeErr := json.NewDecoder(resp.Body).Decode(&out) + _ = resp.Body.Close() + + 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..0634023c9d --- /dev/null +++ b/pkg/accessreview/drivers/segment.go @@ -0,0 +1,323 @@ +// 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) + + // 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: nil, + 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, 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, &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..d3f8eb6f1a --- /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) + // 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] + 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) + assert.Nil(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..744e9fd326 --- /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: + - ancestor:accounts/123456 + pageSize: + - "200" + headers: + Accept: + - application/json + url: https://analyticsadmin.googleapis.com/v1alpha/properties?filter=ancestor%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/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), 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 {