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 {