Add Google Analytics, Dotfile, Segment and Square access-review connectors

Two OAuth2 and two API-key connectors:

- Google Analytics (GA4): OAuth2 with both analytics.readonly and
  analytics.manage.users.readonly (readonly alone 403s on the accounts
  list); v1alpha accessBindings enumerated at account and property level
  and merged by email; manual account picker (Pattern 1) with a
  per-connection probe and name resolver; distinct from Google Workspace.
- Dotfile: API key in the X-DOTFILE-API-KEY header (Pattern 3); GET
  /v1/users (owner/admin, suspended_at) with a static probe.
- Segment (Twilio): Public API token as Bearer with a required Region
  setting (US or EU) mapped to the regional host; GET /users plus per-user
  GET /users/{id} for roles and /invites for pending members; per-connection
  BuildProbeURL.
- Square: OAuth2 (EMPLOYEES_READ) or a personal access token (Pattern 3);
  POST /v2/team-members/search returns email/status/is_owner directly, so no
  role resolution; custom probe and name resolver.

Google Analytics and Square are confidential OAuth clients, wired into the
bootstrap OAuth provider list and .env.example. Segment carries a required
extra setting, so the console add-source dialog maps region onto its
segmentRegion API-key input; without that mapping the value is silently
dropped and the create is rejected.

Cassette-backed driver tests plus unit tests for the Segment probe URL and
the bootstrap OAuth provider list.

Signed-off-by: Aurélien Sibiril <81782+aureliensibiril@users.noreply.github.com>
This commit is contained in:
Aurélien Sibiril
2026-07-11 23:15:03 +02:00
parent 724c169876
commit 60628645ae
31 changed files with 2124 additions and 28 deletions

View File

@@ -145,6 +145,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

View File

@@ -83,6 +83,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;

View File

@@ -67,6 +67,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 {

View File

@@ -0,0 +1,186 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package drivers
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"strconv"
"strings"
"go.probo.inc/probo/pkg/coredata"
)
const (
dotfileAPIHost = "api.dotfile.com"
dotfileUsersPath = "/v1/users"
dotfilePageSize = 100
)
// DotfileDriver lists the users of a single Dotfile workspace. The API key
// (sent in the X-DOTFILE-API-KEY header by the connection transport) is bound
// to one workspace, so GET /v1/users returns every user of that workspace with
// no tenant selector. Pagination is page/limit based.
type DotfileDriver struct {
httpClient *http.Client
}
var _ Driver = (*DotfileDriver)(nil)
type dotfileUser struct {
ID string `json:"id"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
Email string `json:"email"`
Role string `json:"role"`
CreatedAt string `json:"created_at"`
// SuspendedAt is the suspension timestamp; null (decoded as empty) means
// the user is active. The endpoint returns only active users unless
// include_suspended=true is requested, so both states are enumerated.
SuspendedAt string `json:"suspended_at"`
}
type dotfileUsersResponse struct {
Data []dotfileUser `json:"data"`
Pagination struct {
Page int `json:"page"`
Limit int `json:"limit"`
Count int `json:"count"`
} `json:"pagination"`
}
func NewDotfileDriver(httpClient *http.Client) *DotfileDriver {
return &DotfileDriver{httpClient: httpClient}
}
func (d *DotfileDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error) {
var records []AccountRecord
collected := 0
for page := 1; page <= maxPaginationPages; page++ {
resp, err := d.fetchPage(ctx, page)
if err != nil {
return nil, err
}
for _, u := range resp.Data {
email := strings.TrimSpace(u.Email)
if email == "" {
continue
}
active := u.SuspendedAt == ""
records = append(records, AccountRecord{
Email: email,
FullName: dotfileFullName(u, email),
Roles: dotfileRoles(u.Role),
Active: &active,
IsAdmin: dotfileIsAdmin(u.Role),
MFAStatus: coredata.MFAStatusUnknown,
AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown,
AccountType: coredata.AccessReviewEntryAccountTypeUser,
CreatedAt: parseRFC3339Ptr(u.CreatedAt),
ExternalID: u.ID,
})
}
collected += len(resp.Data)
if len(resp.Data) == 0 || collected >= resp.Pagination.Count {
return records, nil
}
}
return nil, fmt.Errorf("cannot list all dotfile accounts: %w", ErrPaginationLimitReached)
}
func (d *DotfileDriver) fetchPage(ctx context.Context, page int) (*dotfileUsersResponse, error) {
q := url.Values{}
q.Set("include_suspended", "true")
q.Set("limit", strconv.Itoa(dotfilePageSize))
q.Set("page", strconv.Itoa(page))
endpoint := url.URL{
Scheme: "https",
Host: dotfileAPIHost,
Path: dotfileUsersPath,
RawQuery: q.Encode(),
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil)
if err != nil {
return nil, fmt.Errorf("cannot create dotfile users request: %w", err)
}
req.Header.Set("Accept", "application/json")
httpResp, err := d.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("cannot execute dotfile users request: %w", err)
}
defer func() {
_ = httpResp.Body.Close()
}()
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
return nil, fmt.Errorf("cannot fetch dotfile users: unexpected status %d", httpResp.StatusCode)
}
var resp dotfileUsersResponse
if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil {
return nil, fmt.Errorf("cannot decode dotfile users response: %w", err)
}
return &resp, nil
}
// dotfileFullName joins the user's first and last names, falling back to the
// email when Dotfile exposes neither.
func dotfileFullName(u dotfileUser, email string) string {
name := strings.TrimSpace(strings.TrimSpace(u.FirstName) + " " + strings.TrimSpace(u.LastName))
if name == "" {
return email
}
return name
}
// dotfileRoles returns the user's single Dotfile role as a one-element slice
// (owner / admin / member / a custom role name), or an empty slice when none
// is set.
func dotfileRoles(role string) []string {
if r := strings.TrimSpace(role); r != "" {
return []string{r}
}
return []string{}
}
// dotfileIsAdmin reports whether the role grants administrative access. Dotfile
// has two system admin roles — owner (can delete the workspace) and admin (can
// change settings / invite) — while member and custom roles do not.
func dotfileIsAdmin(role string) bool {
switch strings.ToLower(strings.TrimSpace(role)) {
case "owner", "admin":
return true
default:
return false
}
}

View File

@@ -0,0 +1,63 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// 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)
}

View File

@@ -0,0 +1,305 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package drivers
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"sort"
"strconv"
"strings"
"go.probo.inc/probo/pkg/coredata"
)
const (
googleAnalyticsAPIHost = "analyticsadmin.googleapis.com"
googleAnalyticsPageSize = 200
// googleAnalyticsAdminRole is the only GA4 predefined role that grants
// administrative access; viewer/analyst/editor do not, and no-cost-data /
// no-revenue-data are data restrictions rather than access levels.
googleAnalyticsAdminRole = "predefinedRoles/admin"
googleAnalyticsRolePrefix = "predefinedRoles/"
)
// GoogleAnalyticsDriver lists the users who have access to a single GA4 account
// and every property beneath it, using the Analytics Admin API v1alpha (the
// only version that exposes accessBindings). Access is granted at two levels —
// account and property — so a user's effective roles are the union of their
// account-level binding and each of their property-level bindings, deduplicated
// by email.
type GoogleAnalyticsDriver struct {
httpClient *http.Client
accountID string
}
var _ Driver = (*GoogleAnalyticsDriver)(nil)
type googleAnalyticsAccessBinding struct {
// User is the email address the binding grants roles to.
User string `json:"user"`
Roles []string `json:"roles"`
}
type googleAnalyticsBindingsResponse struct {
AccessBindings []googleAnalyticsAccessBinding `json:"accessBindings"`
NextPageToken string `json:"nextPageToken"`
}
type googleAnalyticsProperty struct {
// Name is the resource name, e.g. "properties/67890".
Name string `json:"name"`
}
type googleAnalyticsPropertiesResponse struct {
Properties []googleAnalyticsProperty `json:"properties"`
NextPageToken string `json:"nextPageToken"`
}
// googleAnalyticsMember accumulates a user's roles and admin flag across their
// account-level and property-level bindings.
type googleAnalyticsMember struct {
roles map[string]struct{}
isAdmin bool
}
func NewGoogleAnalyticsDriver(httpClient *http.Client, accountID string) *GoogleAnalyticsDriver {
return &GoogleAnalyticsDriver{
httpClient: &http.Client{
Transport: &retryRoundTripper{
next: httpClient.Transport,
maxRetries: 3,
},
},
accountID: accountID,
}
}
func (d *GoogleAnalyticsDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error) {
members := make(map[string]*googleAnalyticsMember)
// Account-level bindings.
if err := d.collectBindings(ctx, members, "v1alpha", "accounts", d.accountID, "accessBindings"); err != nil {
return nil, err
}
// Property-level bindings, one loop per property beneath the account.
propertyIDs, err := d.listProperties(ctx)
if err != nil {
return nil, err
}
for _, propertyID := range propertyIDs {
if err := d.collectBindings(ctx, members, "v1alpha", "properties", propertyID, "accessBindings"); err != nil {
return nil, err
}
}
return googleAnalyticsRecords(members), nil
}
// collectBindings paginates the accessBindings collection under the given
// resource path and folds each binding into members.
func (d *GoogleAnalyticsDriver) collectBindings(ctx context.Context, members map[string]*googleAnalyticsMember, segments ...string) error {
pageToken := ""
for range maxPaginationPages {
endpoint, err := googleAnalyticsURL(pageToken, nil, segments...)
if err != nil {
return err
}
var resp googleAnalyticsBindingsResponse
if err := d.getJSON(ctx, endpoint, &resp); err != nil {
return err
}
for _, b := range resp.AccessBindings {
addGoogleAnalyticsBinding(members, b.User, b.Roles)
}
if resp.NextPageToken == "" {
return nil
}
pageToken = resp.NextPageToken
}
return fmt.Errorf("cannot list all google analytics access bindings: %w", ErrPaginationLimitReached)
}
// listProperties returns the numeric IDs of every property directly beneath the
// account.
func (d *GoogleAnalyticsDriver) listProperties(ctx context.Context) ([]string, error) {
var propertyIDs []string
pageToken := ""
filter := url.Values{"filter": {"parent:accounts/" + d.accountID}}
for range maxPaginationPages {
endpoint, err := googleAnalyticsURL(pageToken, filter, "v1alpha", "properties")
if err != nil {
return nil, err
}
var resp googleAnalyticsPropertiesResponse
if err := d.getJSON(ctx, endpoint, &resp); err != nil {
return nil, err
}
for _, p := range resp.Properties {
if id := strings.TrimPrefix(p.Name, "properties/"); id != "" {
propertyIDs = append(propertyIDs, id)
}
}
if resp.NextPageToken == "" {
return propertyIDs, nil
}
pageToken = resp.NextPageToken
}
return nil, fmt.Errorf("cannot list all google analytics properties: %w", ErrPaginationLimitReached)
}
func (d *GoogleAnalyticsDriver) getJSON(ctx context.Context, endpoint string, out any) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return fmt.Errorf("cannot create google analytics request: %w", err)
}
req.Header.Set("Accept", "application/json")
httpResp, err := d.httpClient.Do(req)
if err != nil {
return fmt.Errorf("cannot execute google analytics request: %w", err)
}
defer func() {
_ = httpResp.Body.Close()
}()
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
return fmt.Errorf("cannot fetch google analytics resource: unexpected status %d", httpResp.StatusCode)
}
if err := json.NewDecoder(httpResp.Body).Decode(out); err != nil {
return fmt.Errorf("cannot decode google analytics response: %w", err)
}
return nil
}
// googleAnalyticsURL builds a v1alpha Admin API URL from path segments, adding
// the shared pageSize, an optional page token, and any extra query values.
func googleAnalyticsURL(pageToken string, extra url.Values, segments ...string) (string, error) {
joined, err := url.JoinPath("https://"+googleAnalyticsAPIHost, segments...)
if err != nil {
return "", fmt.Errorf("cannot build google analytics URL: %w", err)
}
parsed, err := url.Parse(joined)
if err != nil {
return "", fmt.Errorf("cannot parse google analytics URL: %w", err)
}
q := parsed.Query()
q.Set("pageSize", strconv.Itoa(googleAnalyticsPageSize))
for k, vs := range extra {
for _, v := range vs {
q.Add(k, v)
}
}
if pageToken != "" {
q.Set("pageToken", pageToken)
}
parsed.RawQuery = q.Encode()
return parsed.String(), nil
}
// addGoogleAnalyticsBinding folds one access binding into the per-email member
// map, deduplicating roles and setting the admin flag when the admin role is
// present.
func addGoogleAnalyticsBinding(members map[string]*googleAnalyticsMember, user string, roles []string) {
email := strings.ToLower(strings.TrimSpace(user))
if email == "" {
return
}
member, ok := members[email]
if !ok {
member = &googleAnalyticsMember{roles: make(map[string]struct{})}
members[email] = member
}
for _, role := range roles {
role = strings.TrimSpace(role)
if role == "" {
continue
}
member.roles[role] = struct{}{}
if role == googleAnalyticsAdminRole {
member.isAdmin = true
}
}
}
// googleAnalyticsRecords turns the merged member map into a deterministically
// ordered slice of AccountRecords. Active is left nil: GA4 access bindings
// carry no account-status signal.
func googleAnalyticsRecords(members map[string]*googleAnalyticsMember) []AccountRecord {
emails := make([]string, 0, len(members))
for email := range members {
emails = append(emails, email)
}
sort.Strings(emails)
records := make([]AccountRecord, 0, len(members))
for _, email := range emails {
member := members[email]
roles := make([]string, 0, len(member.roles))
for role := range member.roles {
roles = append(roles, strings.TrimPrefix(role, googleAnalyticsRolePrefix))
}
sort.Strings(roles)
records = append(records, AccountRecord{
Email: email,
FullName: email,
Roles: roles,
IsAdmin: member.isAdmin,
MFAStatus: coredata.MFAStatusUnknown,
AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown,
AccountType: coredata.AccessReviewEntryAccountTypeUser,
ExternalID: email,
})
}
return records
}

View File

@@ -0,0 +1,59 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// 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)
}

View File

@@ -1661,3 +1661,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
}

View File

@@ -25,7 +25,9 @@ import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"strconv"
"strings"
)
// Organization represents a tenant/workspace/team/group surfaced by a
@@ -470,3 +472,77 @@ func ListClickUpOrganizations(ctx context.Context, httpClient *http.Client) ([]O
return result, nil
}
// ListGoogleAnalyticsOrganizations fetches the GA4 accounts the authenticated
// Google user can access, surfacing each account's numeric ID as the picker
// slug. Listing accounts requires the analytics.readonly scope.
func ListGoogleAnalyticsOrganizations(ctx context.Context, httpClient *http.Client) ([]Organization, error) {
var orgs []Organization
pageToken := ""
for range maxPaginationPages {
q := url.Values{}
q.Set("pageSize", strconv.Itoa(googleAnalyticsPageSize))
if pageToken != "" {
q.Set("pageToken", pageToken)
}
endpoint := url.URL{
Scheme: "https",
Host: googleAnalyticsAPIHost,
Path: "/v1alpha/accounts",
RawQuery: q.Encode(),
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil)
if err != nil {
return nil, fmt.Errorf("cannot create google analytics accounts request: %w", err)
}
req.Header.Set("Accept", "application/json")
resp, err := httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("cannot fetch google analytics accounts: %w", err)
}
var out struct {
Accounts []struct {
Name string `json:"name"`
DisplayName string `json:"displayName"`
} `json:"accounts"`
NextPageToken string `json:"nextPageToken"`
}
decodeErr := json.NewDecoder(resp.Body).Decode(&out)
status := resp.StatusCode
_ = resp.Body.Close()
if status != http.StatusOK {
return nil, fmt.Errorf("cannot fetch google analytics accounts: unexpected status %d", status)
}
if decodeErr != nil {
return nil, fmt.Errorf("cannot decode google analytics accounts response: %w", decodeErr)
}
for _, a := range out.Accounts {
id := strings.TrimPrefix(a.Name, "accounts/")
if id == "" {
continue
}
orgs = append(orgs, Organization{Slug: id, DisplayName: a.DisplayName})
}
if out.NextPageToken == "" {
return orgs, nil
}
pageToken = out.NextPageToken
}
return nil, fmt.Errorf("cannot list all google analytics accounts: %w", ErrPaginationLimitReached)
}

View File

@@ -0,0 +1,320 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package drivers
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"sort"
"strconv"
"strings"
"go.probo.inc/probo/pkg/coredata"
)
const (
segmentPageSize = 200
// segmentWorkspaceOwnerRole is the only Segment role that grants
// workspace-wide administrative access; the resource-scoped *Admin roles
// (Source Admin, Warehouse Admin, …) administer a single resource, not the
// workspace.
segmentWorkspaceOwnerRole = "Workspace Owner"
)
// SegmentDriver lists the members of a single Twilio Segment workspace. The
// Public API token (sent as Authorization: Bearer by the connection transport)
// is bound to one workspace on one regional host. GET /users returns members
// without their roles, so each member's roles are fetched with a follow-up GET
// /users/{id} (an unavoidable N+1); GET /invites adds pending invitations as
// inactive members.
type SegmentDriver struct {
httpClient *http.Client
baseURL string
}
var _ Driver = (*SegmentDriver)(nil)
type segmentUser struct {
ID string `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
}
type segmentPermission struct {
RoleName string `json:"roleName"`
}
type segmentPaginationOut struct {
// Next is absent (nil) on the last page.
Next *string `json:"next"`
}
type segmentUsersResponse struct {
Data struct {
Users []segmentUser `json:"users"`
Pagination segmentPaginationOut `json:"pagination"`
} `json:"data"`
}
type segmentUserResponse struct {
Data struct {
User struct {
Permissions []segmentPermission `json:"permissions"`
} `json:"user"`
} `json:"data"`
}
type segmentInvitesResponse struct {
Data struct {
Invites []string `json:"invites"`
Pagination segmentPaginationOut `json:"pagination"`
} `json:"data"`
}
func NewSegmentDriver(httpClient *http.Client, baseURL string) *SegmentDriver {
return &SegmentDriver{
httpClient: &http.Client{
Transport: &retryRoundTripper{
next: httpClient.Transport,
maxRetries: 3,
},
},
baseURL: baseURL,
}
}
func (d *SegmentDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error) {
base, err := url.Parse(d.baseURL)
if err != nil {
return nil, fmt.Errorf("cannot parse segment base URL: %w", err)
}
users, err := d.listUsers(ctx, base)
if err != nil {
return nil, err
}
records := make([]AccountRecord, 0, len(users))
for _, u := range users {
email := strings.TrimSpace(u.Email)
if email == "" {
continue
}
perms, err := d.userPermissions(ctx, base, u.ID)
if err != nil {
return nil, err
}
roles, isAdmin := segmentRolesAndAdmin(perms)
active := true
records = append(records, AccountRecord{
Email: email,
FullName: segmentFullName(u.Name, email),
Roles: roles,
Active: &active,
IsAdmin: isAdmin,
MFAStatus: coredata.MFAStatusUnknown,
AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown,
AccountType: coredata.AccessReviewEntryAccountTypeUser,
ExternalID: u.ID,
})
}
invites, err := d.listInvites(ctx, base)
if err != nil {
return nil, err
}
for _, email := range invites {
email = strings.TrimSpace(email)
if email == "" {
continue
}
// A pending invite carries no role and no stable id at the workspace
// level, so it is surfaced as an inactive member keyed by email.
inactive := false
records = append(records, AccountRecord{
Email: email,
FullName: email,
Roles: []string{},
Active: &inactive,
MFAStatus: coredata.MFAStatusUnknown,
AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown,
AccountType: coredata.AccessReviewEntryAccountTypeUser,
ExternalID: email,
})
}
return records, nil
}
func (d *SegmentDriver) listUsers(ctx context.Context, base *url.URL) ([]segmentUser, error) {
var users []segmentUser
cursor := ""
for range maxPaginationPages {
endpoint := *base
endpoint.Path = "/users"
q := url.Values{}
q.Set("pagination.count", strconv.Itoa(segmentPageSize))
if cursor != "" {
q.Set("pagination.cursor", cursor)
}
endpoint.RawQuery = q.Encode()
var resp segmentUsersResponse
if err := d.getJSON(ctx, endpoint.String(), &resp); err != nil {
return nil, err
}
users = append(users, resp.Data.Users...)
if resp.Data.Pagination.Next == nil || *resp.Data.Pagination.Next == "" {
return users, nil
}
cursor = *resp.Data.Pagination.Next
}
return nil, fmt.Errorf("cannot list all segment users: %w", ErrPaginationLimitReached)
}
func (d *SegmentDriver) userPermissions(ctx context.Context, base *url.URL, userID string) ([]segmentPermission, error) {
endpoint := *base
// Path holds the decoded value; endpoint.String() escapes the id once.
endpoint.Path = "/users/" + userID
var resp segmentUserResponse
if err := d.getJSON(ctx, endpoint.String(), &resp); err != nil {
return nil, err
}
return resp.Data.User.Permissions, nil
}
func (d *SegmentDriver) listInvites(ctx context.Context, base *url.URL) ([]string, error) {
var invites []string
cursor := ""
for range maxPaginationPages {
endpoint := *base
endpoint.Path = "/invites"
q := url.Values{}
q.Set("pagination.count", strconv.Itoa(segmentPageSize))
if cursor != "" {
q.Set("pagination.cursor", cursor)
}
endpoint.RawQuery = q.Encode()
var resp segmentInvitesResponse
if err := d.getJSON(ctx, endpoint.String(), &resp); err != nil {
return nil, err
}
invites = append(invites, resp.Data.Invites...)
if resp.Data.Pagination.Next == nil || *resp.Data.Pagination.Next == "" {
return invites, nil
}
cursor = *resp.Data.Pagination.Next
}
return nil, fmt.Errorf("cannot list all segment invites: %w", ErrPaginationLimitReached)
}
func (d *SegmentDriver) getJSON(ctx context.Context, endpoint string, out any) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return fmt.Errorf("cannot create segment request: %w", err)
}
req.Header.Set("Accept", "application/json")
httpResp, err := d.httpClient.Do(req)
if err != nil {
return fmt.Errorf("cannot execute segment request: %w", err)
}
defer func() {
_ = httpResp.Body.Close()
}()
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
return fmt.Errorf("cannot fetch segment resource: unexpected status %d", httpResp.StatusCode)
}
if err := json.NewDecoder(httpResp.Body).Decode(out); err != nil {
return fmt.Errorf("cannot decode segment response: %w", err)
}
return nil
}
// segmentFullName returns the member's name, falling back to the email when
// Segment stores an empty name.
func segmentFullName(name, email string) string {
if n := strings.TrimSpace(name); n != "" {
return n
}
return email
}
// segmentRolesAndAdmin collapses a member's permission entries into a
// de-duplicated, sorted set of role names and reports whether any of them is
// the workspace-level Workspace Owner role.
func segmentRolesAndAdmin(perms []segmentPermission) ([]string, bool) {
seen := make(map[string]struct{})
isAdmin := false
for _, p := range perms {
name := strings.TrimSpace(p.RoleName)
if name == "" {
continue
}
seen[name] = struct{}{}
if strings.EqualFold(name, segmentWorkspaceOwnerRole) {
isAdmin = true
}
}
roles := make([]string, 0, len(seen))
for name := range seen {
roles = append(roles, name)
}
sort.Strings(roles)
return roles, isAdmin
}

View File

@@ -0,0 +1,64 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package drivers
import (
"context"
"os"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestSegmentDriver(t *testing.T) {
t.Parallel()
rec := newRecorder(t, "testdata/segment", "SEGMENT_API_TOKEN")
client := newVCRClient(rec, bearerAuth(os.Getenv("SEGMENT_API_TOKEN")))
driver := NewSegmentDriver(client, "https://api.segmentapis.com")
records, err := driver.ListAccounts(context.Background())
require.NoError(t, err)
require.Len(t, records, 3)
// First user: empty name falls back to email; Workspace Owner ⇒ admin.
owner := records[0]
assert.Equal(t, "sgJDWk3K21k6LE3tLU9nRK", owner.ExternalID)
assert.Equal(t, "papi@example.com", owner.Email)
assert.Equal(t, "papi@example.com", owner.FullName)
assert.True(t, owner.IsAdmin)
assert.Equal(t, []string{"Workspace Owner"}, owner.Roles)
require.NotNil(t, owner.Active)
assert.True(t, *owner.Active)
// Second user: named; resource-scoped read-only role ⇒ not admin.
member := records[1]
assert.Equal(t, "i2VTJURQprNfqdwjLFPWYx", member.ExternalID)
assert.Equal(t, "Sloth", member.FullName)
assert.False(t, member.IsAdmin)
assert.Equal(t, []string{"Source Read-only"}, member.Roles)
require.NotNil(t, member.Active)
assert.True(t, *member.Active)
// Pending invite: inactive, no roles, keyed by email.
invite := records[2]
assert.Equal(t, "foo@example.com", invite.Email)
assert.Equal(t, "foo@example.com", invite.ExternalID)
assert.False(t, invite.IsAdmin)
assert.Empty(t, invite.Roles)
require.NotNil(t, invite.Active)
assert.False(t, *invite.Active)
}

View File

@@ -0,0 +1,174 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// 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
}

View File

@@ -0,0 +1,62 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// 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)
}

View File

@@ -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

View File

@@ -0,0 +1,96 @@
---
# Hand-authored cassette for the GA4 Admin API v1alpha (Bearer token stripped by
# the recorder). The driver lists account-level accessBindings, then the
# properties beneath the account, then each property's accessBindings, merging a
# user's roles across levels by email. alice appears at both levels (admin +
# viewer) to exercise the merge; carol appears only at the property level. The
# AccessBinding shape (name, user, roles[]) and property list shape mirror the
# live API.
version: 2
interactions:
- id: 0
request:
proto: HTTP/1.1
proto_major: 1
proto_minor: 1
content_length: 0
host: analyticsadmin.googleapis.com
form:
pageSize:
- "200"
headers:
Accept:
- application/json
url: https://analyticsadmin.googleapis.com/v1alpha/accounts/123456/accessBindings?pageSize=200
method: GET
response:
proto: HTTP/2.0
proto_major: 2
proto_minor: 0
content_length: -1
uncompressed: true
body: '{"accessBindings":[{"name":"accounts/123456/accessBindings/abc123","user":"alice@example.com","roles":["predefinedRoles/admin"]},{"name":"accounts/123456/accessBindings/def456","user":"bob@example.com","roles":["predefinedRoles/analyst"]}]}'
headers:
Content-Type:
- application/json
status: 200 OK
code: 200
duration: 100ms
- id: 1
request:
proto: HTTP/1.1
proto_major: 1
proto_minor: 1
content_length: 0
host: analyticsadmin.googleapis.com
form:
filter:
- parent:accounts/123456
pageSize:
- "200"
headers:
Accept:
- application/json
url: https://analyticsadmin.googleapis.com/v1alpha/properties?filter=parent%3Aaccounts%2F123456&pageSize=200
method: GET
response:
proto: HTTP/2.0
proto_major: 2
proto_minor: 0
content_length: -1
uncompressed: true
body: '{"properties":[{"name":"properties/67890","displayName":"Acme Website","parent":"accounts/123456"}]}'
headers:
Content-Type:
- application/json
status: 200 OK
code: 200
duration: 100ms
- id: 2
request:
proto: HTTP/1.1
proto_major: 1
proto_minor: 1
content_length: 0
host: analyticsadmin.googleapis.com
form:
pageSize:
- "200"
headers:
Accept:
- application/json
url: https://analyticsadmin.googleapis.com/v1alpha/properties/67890/accessBindings?pageSize=200
method: GET
response:
proto: HTTP/2.0
proto_major: 2
proto_minor: 0
content_length: -1
uncompressed: true
body: '{"accessBindings":[{"name":"properties/67890/accessBindings/ghi789","user":"alice@example.com","roles":["predefinedRoles/viewer"]},{"name":"properties/67890/accessBindings/jkl012","user":"carol@example.com","roles":["predefinedRoles/analyst"]}]}'
headers:
Content-Type:
- application/json
status: 200 OK
code: 200
duration: 100ms

View File

@@ -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

View File

@@ -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

View File

@@ -490,6 +490,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 == "" {
@@ -614,6 +616,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"}},
}

View File

@@ -617,7 +617,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()

View File

@@ -43,9 +43,11 @@ func NewBuiltinRegistry() *Registry {
datadogRegistration(),
deepgramRegistration(),
docusignRegistration(),
dotfileRegistration(),
grafanaRegistration(),
githubRegistration(),
gitlabRegistration(),
googleAnalyticsRegistration(),
googleWorkspaceRegistration(),
herokuRegistration(),
hubspotRegistration(),
@@ -72,10 +74,12 @@ func NewBuiltinRegistry() *Registry {
renderRegistration(),
resendRegistration(),
scalewayRegistration(),
segmentRegistration(),
sendgridRegistration(),
sentryRegistration(),
signozRegistration(),
slackRegistration(),
squareRegistration(),
supabaseRegistration(),
tailscaleRegistration(),
tallyRegistration(),

View File

@@ -0,0 +1,48 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// 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
},
}
}

View File

@@ -0,0 +1,74 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// 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})
},
}
}

View File

@@ -48,6 +48,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
@@ -615,3 +617,47 @@ func probePostHog(
return nil
}
// 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)
}

View File

@@ -223,6 +223,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()

View File

@@ -0,0 +1,58 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// 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
},
}
}

View File

@@ -0,0 +1,53 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// 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)
},
}
}

View File

@@ -60,32 +60,36 @@ const (
ConnectorProviderClickUp ConnectorProvider = "CLICKUP"
// ConnectorProviderClerk is disabled (unregistered) but kept in IsValid
// and the GraphQL enum so existing stored CLERK rows still validate.
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"
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 (
@@ -150,6 +154,10 @@ func ConnectorProviders() []ConnectorProvider {
ConnectorProviderYousign,
ConnectorProviderRailway,
ConnectorProviderCrisp,
ConnectorProviderDotfile,
ConnectorProviderSegment,
ConnectorProviderSquare,
ConnectorProviderGoogleAnalytics,
}
}
@@ -210,7 +218,11 @@ func (v ConnectorProvider) IsValid() bool {
ConnectorProviderScaleway,
ConnectorProviderYousign,
ConnectorProviderRailway,
ConnectorProviderCrisp:
ConnectorProviderCrisp,
ConnectorProviderDotfile,
ConnectorProviderSegment,
ConnectorProviderSquare,
ConnectorProviderGoogleAnalytics:
return true
}

View File

@@ -194,6 +194,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

View File

@@ -0,0 +1,18 @@
-- Copyright (c) 2026 Probo Inc <hello@probo.com>.
--
-- 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';

View File

@@ -328,6 +328,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 {

View File

@@ -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 {
@@ -202,6 +211,7 @@ input CreateAPIKeyConnectorInput {
langfuseBaseUrl: String
scalewayOrganizationId: String
crispWebsiteId: String
segmentRegion: String
}
type CreateAPIKeyConnectorPayload {