Add access source drivers

Add Driver interface and implementations for Google
Workspace, Linear, Slack, 1Password, HubSpot, DocuSign,
Notion, Brex, Tally, Cloudflare, CSV, Probo memberships,
Sentry, OpenAI, Supabase, GitHub, Intercom, and Resend.
Include name resolvers, VCR test infrastructure with
cassettes, and RFC 5988 link header parser.

Signed-off-by: Aurélien Sibiril <81782+aureliensibiril@users.noreply.github.com>
This commit is contained in:
Aurélien Sibiril
2026-04-02 11:51:19 +02:00
parent 761840929f
commit 2320e1e0be
53 changed files with 6051 additions and 0 deletions

View File

@@ -0,0 +1,127 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.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"
"go.probo.inc/probo/pkg/coredata"
)
// BrexDriver fetches users from Brex via OAuth2-authenticated REST API
// requests.
type BrexDriver struct {
httpClient *http.Client
}
var _ Driver = (*BrexDriver)(nil)
type brexUsersResponse struct {
Items []struct {
ID string `json:"id"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
Email string `json:"email"`
Status string `json:"status"`
Role string `json:"role"`
} `json:"items"`
NextCursor string `json:"next_cursor"`
}
const brexUsersEndpoint = "https://platform.brexapis.com/v2/users"
func NewBrexDriver(httpClient *http.Client) *BrexDriver {
return &BrexDriver{
httpClient: httpClient,
}
}
func (d *BrexDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error) {
var (
records []AccountRecord
cursor *string
)
for range maxPaginationPages {
resp, err := d.queryUsers(ctx, cursor)
if err != nil {
return nil, err
}
for _, u := range resp.Items {
record := AccountRecord{
Email: u.Email,
FullName: u.FirstName + " " + u.LastName,
Role: u.Role,
Active: u.Status == "ACTIVE",
IsAdmin: false,
ExternalID: u.ID,
MFAStatus: coredata.MFAStatusUnknown,
AuthMethod: coredata.AccessEntryAuthMethodUnknown,
AccountType: coredata.AccessEntryAccountTypeUser,
}
if record.Email != "" {
records = append(records, record)
}
}
if resp.NextCursor == "" {
return records, nil
}
nextCursor := resp.NextCursor
cursor = &nextCursor
}
return nil, fmt.Errorf("cannot list all brex accounts: %w", ErrPaginationLimitReached)
}
func (d *BrexDriver) queryUsers(ctx context.Context, cursor *string) (*brexUsersResponse, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, brexUsersEndpoint, nil)
if err != nil {
return nil, fmt.Errorf("cannot create brex users request: %w", err)
}
req.Header.Set("Accept", "application/json")
req.Header.Set("Content-Type", "application/json")
if cursor != nil {
q := req.URL.Query()
q.Set("cursor", *cursor)
req.URL.RawQuery = q.Encode()
}
httpResp, err := d.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("cannot execute brex users request: %w", err)
}
defer func() {
_ = httpResp.Body.Close()
}()
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
return nil, fmt.Errorf("cannot fetch brex users: unexpected status %d", httpResp.StatusCode)
}
var resp brexUsersResponse
if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil {
return nil, fmt.Errorf("cannot decode brex users response: %w", err)
}
return &resp, nil
}

View File

@@ -0,0 +1,41 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.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 TestBrexDriver(t *testing.T) {
t.Parallel()
rec := newRecorder(t, "testdata/brex", "BREX_TOKEN")
client := newVCRClient(rec, bearerAuth(os.Getenv("BREX_TOKEN")))
driver := NewBrexDriver(client)
records, err := driver.ListAccounts(context.Background())
require.NoError(t, err)
require.NotEmpty(t, records)
r := records[0]
assert.NotEmpty(t, r.Email)
assert.NotEmpty(t, r.FullName)
assert.NotEmpty(t, r.ExternalID)
}

View File

@@ -0,0 +1,241 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.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"
"strings"
"go.probo.inc/probo/pkg/coredata"
)
// CloudflareDriver fetches account members from the Cloudflare API.
type CloudflareDriver struct {
httpClient *http.Client
}
var _ Driver = (*CloudflareDriver)(nil)
type cloudflareAccount struct {
ID string `json:"id"`
Name string `json:"name"`
}
type cloudflareListAccountsResponse struct {
Result []cloudflareAccount `json:"result"`
ResultInfo cloudflareResultInfo `json:"result_info"`
}
type cloudflareResultInfo struct {
Page int `json:"page"`
PerPage int `json:"per_page"`
TotalPages int `json:"total_pages"`
Count int `json:"count"`
TotalCount int `json:"total_count"`
}
type cloudflareListMembersResponse struct {
Result []struct {
ID string `json:"id"`
Status string `json:"status"`
User struct {
ID string `json:"id"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
Email string `json:"email"`
TwoFactorEnabled bool `json:"two_factor_authentication_enabled"`
} `json:"user"`
Roles []struct {
ID string `json:"id"`
Name string `json:"name"`
} `json:"roles"`
} `json:"result"`
ResultInfo cloudflareResultInfo `json:"result_info"`
}
func NewCloudflareDriver(httpClient *http.Client) *CloudflareDriver {
return &CloudflareDriver{
httpClient: httpClient,
}
}
func (d *CloudflareDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error) {
accounts, err := d.queryAllAccounts(ctx)
if err != nil {
return nil, err
}
var records []AccountRecord
for _, account := range accounts {
members, err := d.queryAllMembers(ctx, account.ID)
if err != nil {
return nil, fmt.Errorf("cannot fetch members for cloudflare account %s: %w", account.ID, err)
}
records = append(records, members...)
}
return records, nil
}
func (d *CloudflareDriver) queryAllAccounts(ctx context.Context) ([]cloudflareAccount, error) {
var accounts []cloudflareAccount
for page := range maxPaginationPages {
resp, err := d.queryAccounts(ctx, page+1)
if err != nil {
return nil, err
}
accounts = append(accounts, resp.Result...)
if page+1 >= resp.ResultInfo.TotalPages {
return accounts, nil
}
}
return nil, fmt.Errorf("cannot list all cloudflare accounts: %w", ErrPaginationLimitReached)
}
func (d *CloudflareDriver) queryAccounts(ctx context.Context, page int) (*cloudflareListAccountsResponse, error) {
url := fmt.Sprintf(
"https://api.cloudflare.com/client/v4/accounts?page=%d&per_page=50",
page,
)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("cannot create cloudflare accounts request: %w", err)
}
req.Header.Set("Accept", "application/json")
req.Header.Set("Content-Type", "application/json")
httpResp, err := d.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("cannot execute cloudflare accounts request: %w", err)
}
defer func() {
_ = httpResp.Body.Close()
}()
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
return nil, fmt.Errorf("cannot fetch cloudflare accounts: unexpected status %d", httpResp.StatusCode)
}
var resp cloudflareListAccountsResponse
if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil {
return nil, fmt.Errorf("cannot decode cloudflare accounts response: %w", err)
}
return &resp, nil
}
func (d *CloudflareDriver) queryAllMembers(ctx context.Context, accountID string) ([]AccountRecord, error) {
var records []AccountRecord
for page := range maxPaginationPages {
resp, err := d.queryMembers(ctx, accountID, page+1)
if err != nil {
return nil, err
}
for _, m := range resp.Result {
roles := make([]string, 0, len(m.Roles))
for _, r := range m.Roles {
roles = append(roles, r.Name)
}
role := "Member"
if len(roles) > 0 {
role = strings.Join(roles, ", ")
}
isAdmin := false
for _, r := range m.Roles {
if r.Name == "Super Administrator - All Privileges" || r.Name == "Administrator" {
isAdmin = true
break
}
}
mfaStatus := coredata.MFAStatusUnknown
if m.User.TwoFactorEnabled {
mfaStatus = coredata.MFAStatusEnabled
}
record := AccountRecord{
Email: m.User.Email,
FullName: m.User.FirstName + " " + m.User.LastName,
Role: role,
Active: m.Status == "accepted",
IsAdmin: isAdmin,
ExternalID: m.ID,
MFAStatus: mfaStatus,
AuthMethod: coredata.AccessEntryAuthMethodUnknown,
AccountType: coredata.AccessEntryAccountTypeUser,
}
if record.Email != "" {
records = append(records, record)
}
}
if page+1 >= resp.ResultInfo.TotalPages {
return records, nil
}
}
return nil, fmt.Errorf("cannot list all cloudflare members: %w", ErrPaginationLimitReached)
}
func (d *CloudflareDriver) queryMembers(ctx context.Context, accountID string, page int) (*cloudflareListMembersResponse, error) {
url := fmt.Sprintf(
"https://api.cloudflare.com/client/v4/accounts/%s/members?page=%d&per_page=50",
accountID,
page,
)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("cannot create cloudflare members request: %w", err)
}
req.Header.Set("Accept", "application/json")
req.Header.Set("Content-Type", "application/json")
httpResp, err := d.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("cannot execute cloudflare members request: %w", err)
}
defer func() {
_ = httpResp.Body.Close()
}()
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
return nil, fmt.Errorf("cannot fetch cloudflare members: unexpected status %d", httpResp.StatusCode)
}
var resp cloudflareListMembersResponse
if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil {
return nil, fmt.Errorf("cannot decode cloudflare members response: %w", err)
}
return &resp, nil
}

View File

@@ -0,0 +1,42 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.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 TestCloudflareDriver(t *testing.T) {
t.Parallel()
rec := newRecorder(t, "testdata/cloudflare", "CLOUDFLARE_TOKEN")
client := newVCRClient(rec, bearerAuth(os.Getenv("CLOUDFLARE_TOKEN")))
driver := NewCloudflareDriver(client)
records, err := driver.ListAccounts(context.Background())
require.NoError(t, err)
require.NotEmpty(t, records)
r := records[0]
assert.NotEmpty(t, r.Email)
assert.NotEmpty(t, r.FullName)
assert.NotEmpty(t, r.ExternalID)
assert.NotEmpty(t, r.Role)
}

View File

@@ -0,0 +1,108 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.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/csv"
"fmt"
"io"
"strings"
"go.probo.inc/probo/pkg/coredata"
)
// CSVDriver supports both identity and access use cases from uploaded CSV
// files. No external connector is needed.
//
// Expected CSV columns (header required): email, full_name, role, job_title,
// is_admin, active, external_id
type CSVDriver struct {
reader io.Reader
}
func NewCSVDriver(reader io.Reader) *CSVDriver {
return &CSVDriver{reader: reader}
}
func (d *CSVDriver) ListAccounts(_ context.Context) ([]AccountRecord, error) {
r := csv.NewReader(d.reader)
r.FieldsPerRecord = -1
// Read header
header, err := r.Read()
if err != nil {
return nil, fmt.Errorf("cannot read CSV header: %w", err)
}
colIndex := make(map[string]int)
for i, col := range header {
colIndex[strings.TrimSpace(strings.ToLower(col))] = i
}
if _, ok := colIndex["email"]; !ok {
return nil, fmt.Errorf("cannot parse CSV: missing required column email")
}
var records []AccountRecord
for {
row, err := r.Read()
if err == io.EOF {
break
}
if err != nil {
return nil, fmt.Errorf("cannot read CSV row: %w", err)
}
record := AccountRecord{
MFAStatus: coredata.MFAStatusUnknown,
AuthMethod: coredata.AccessEntryAuthMethodUnknown,
AccountType: coredata.AccessEntryAccountTypeUser,
}
if idx, ok := colIndex["email"]; ok && idx < len(row) {
record.Email = strings.TrimSpace(row[idx])
}
if idx, ok := colIndex["full_name"]; ok && idx < len(row) {
record.FullName = strings.TrimSpace(row[idx])
}
if idx, ok := colIndex["role"]; ok && idx < len(row) {
record.Role = strings.TrimSpace(row[idx])
}
if idx, ok := colIndex["job_title"]; ok && idx < len(row) {
record.JobTitle = strings.TrimSpace(row[idx])
}
if idx, ok := colIndex["is_admin"]; ok && idx < len(row) {
record.IsAdmin = strings.TrimSpace(strings.ToLower(row[idx])) == "true"
}
if idx, ok := colIndex["active"]; ok && idx < len(row) {
record.Active = strings.TrimSpace(strings.ToLower(row[idx])) == "true"
}
if idx, ok := colIndex["external_id"]; ok && idx < len(row) {
record.ExternalID = strings.TrimSpace(row[idx])
}
if idx, ok := colIndex["account_type"]; ok && idx < len(row) {
if strings.TrimSpace(strings.ToUpper(row[idx])) == "SERVICE_ACCOUNT" {
record.AccountType = coredata.AccessEntryAccountTypeServiceAccount
}
}
if record.Email != "" {
records = append(records, record)
}
}
return records, nil
}

View File

@@ -0,0 +1,52 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.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"
"strings"
"testing"
)
func TestCSVDriverRequiresEmailHeader(t *testing.T) {
t.Parallel()
driver := NewCSVDriver(strings.NewReader("full_name,role\nJane Doe,Admin\n"))
_, err := driver.ListAccounts(context.Background())
if err == nil {
t.Fatalf("expected error when email header is missing")
}
}
func TestCSVDriverParsesRequiredAndOptionalColumns(t *testing.T) {
t.Parallel()
driver := NewCSVDriver(strings.NewReader(
"email,full_name,role,external_id\njane@example.com,Jane Doe,Admin,42\n",
))
records, err := driver.ListAccounts(context.Background())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(records) != 1 {
t.Fatalf("expected 1 record, got %d", len(records))
}
if records[0].Email != "jane@example.com" {
t.Fatalf("unexpected email: %s", records[0].Email)
}
if records[0].ExternalID != "42" {
t.Fatalf("unexpected external id: %s", records[0].ExternalID)
}
}

View File

@@ -0,0 +1,206 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.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"
"strconv"
"strings"
"time"
"go.probo.inc/probo/pkg/coredata"
)
// DocuSignDriver fetches account users from DocuSign via OAuth2-authenticated
// REST API requests. It auto-discovers the account ID and base URI from the
// OAuth2 userinfo endpoint, then paginates through the eSignature Users API.
type DocuSignDriver struct {
httpClient *http.Client
}
var _ Driver = (*DocuSignDriver)(nil)
type docusignUserInfoResponse struct {
Accounts []struct {
AccountID string `json:"account_id"`
IsDefault bool `json:"is_default"`
BaseURI string `json:"base_uri"`
} `json:"accounts"`
}
type docusignUsersResponse struct {
Users []struct {
UserID string `json:"userId"`
UserName string `json:"userName"`
Email string `json:"email"`
UserStatus string `json:"userStatus"`
IsAdmin string `json:"isAdmin"`
CreatedDateTime string `json:"createdDateTime"`
LastLogin string `json:"lastLogin"`
PermissionProfileName string `json:"permissionProfileName"`
JobTitle string `json:"jobTitle"`
} `json:"users"`
ResultSetSize string `json:"resultSetSize"`
TotalSetSize string `json:"totalSetSize"`
StartPosition string `json:"startPosition"`
EndPosition string `json:"endPosition"`
}
const (
docusignUserInfoEndpoint = "https://account.docusign.com/oauth/userinfo"
docusignUsersPageSize = 100
)
func NewDocuSignDriver(httpClient *http.Client) *DocuSignDriver {
return &DocuSignDriver{
httpClient: httpClient,
}
}
func (d *DocuSignDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error) {
accountID, baseURI, err := d.discoverAccount(ctx)
if err != nil {
return nil, fmt.Errorf("cannot discover docusign account: %w", err)
}
var records []AccountRecord
startPosition := 0
for range maxPaginationPages {
resp, err := d.queryUsers(ctx, baseURI, accountID, startPosition)
if err != nil {
return nil, err
}
for _, u := range resp.Users {
record := AccountRecord{
Email: u.Email,
FullName: u.UserName,
Role: u.PermissionProfileName,
JobTitle: u.JobTitle,
Active: strings.EqualFold(u.UserStatus, "active"),
IsAdmin: strings.EqualFold(u.IsAdmin, "True"),
ExternalID: u.UserID,
MFAStatus: coredata.MFAStatusUnknown,
AuthMethod: coredata.AccessEntryAuthMethodUnknown,
AccountType: coredata.AccessEntryAccountTypeUser,
}
if u.LastLogin != "" {
if t, err := time.Parse(time.RFC3339, u.LastLogin); err == nil {
record.LastLogin = &t
}
}
if u.CreatedDateTime != "" {
if t, err := time.Parse(time.RFC3339, u.CreatedDateTime); err == nil {
record.CreatedAt = &t
}
}
if record.Email != "" {
records = append(records, record)
}
}
totalSetSize, err := strconv.Atoi(resp.TotalSetSize)
if err != nil {
return nil, fmt.Errorf("cannot parse docusign total set size %q: %w", resp.TotalSetSize, err)
}
endPosition, err := strconv.Atoi(resp.EndPosition)
if err != nil {
return nil, fmt.Errorf("cannot parse docusign end position %q: %w", resp.EndPosition, err)
}
if totalSetSize == 0 || endPosition >= totalSetSize-1 {
return records, nil
}
startPosition = endPosition + 1
}
return nil, fmt.Errorf("cannot list all docusign accounts: %w", ErrPaginationLimitReached)
}
func (d *DocuSignDriver) discoverAccount(ctx context.Context) (accountID string, baseURI string, err error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, docusignUserInfoEndpoint, nil)
if err != nil {
return "", "", fmt.Errorf("cannot create docusign userinfo request: %w", err)
}
req.Header.Set("Accept", "application/json")
httpResp, err := d.httpClient.Do(req)
if err != nil {
return "", "", fmt.Errorf("cannot execute docusign userinfo request: %w", err)
}
defer func() {
_ = httpResp.Body.Close()
}()
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
return "", "", fmt.Errorf("cannot fetch docusign userinfo: unexpected status %d", httpResp.StatusCode)
}
var resp docusignUserInfoResponse
if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil {
return "", "", fmt.Errorf("cannot decode docusign userinfo response: %w", err)
}
for _, account := range resp.Accounts {
if account.IsDefault {
return account.AccountID, account.BaseURI, nil
}
}
if len(resp.Accounts) > 0 {
return resp.Accounts[0].AccountID, resp.Accounts[0].BaseURI, nil
}
return "", "", fmt.Errorf("no docusign accounts found in userinfo response")
}
func (d *DocuSignDriver) queryUsers(ctx context.Context, baseURI string, accountID string, startPosition int) (*docusignUsersResponse, error) {
url := fmt.Sprintf("%s/restapi/v2.1/accounts/%s/users?additional_info=true&count=%d&start_position=%d",
baseURI, accountID, docusignUsersPageSize, startPosition)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("cannot create docusign 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 docusign users request: %w", err)
}
defer func() {
_ = httpResp.Body.Close()
}()
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
return nil, fmt.Errorf("cannot fetch docusign users: unexpected status %d", httpResp.StatusCode)
}
var resp docusignUsersResponse
if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil {
return nil, fmt.Errorf("cannot decode docusign users response: %w", err)
}
return &resp, nil
}

View File

@@ -0,0 +1,42 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.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 TestDocuSignDriver(t *testing.T) {
t.Parallel()
rec := newRecorder(t, "testdata/docusign", "DOCUSIGN_TOKEN")
client := newVCRClient(rec, bearerAuth(os.Getenv("DOCUSIGN_TOKEN")))
driver := NewDocuSignDriver(client)
records, err := driver.ListAccounts(context.Background())
require.NoError(t, err)
require.NotEmpty(t, records)
r := records[0]
assert.NotEmpty(t, r.Email)
assert.NotEmpty(t, r.FullName)
assert.NotEmpty(t, r.ExternalID)
assert.NotEmpty(t, r.Role)
}

View File

@@ -0,0 +1,59 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.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"
"fmt"
"time"
"go.probo.inc/probo/pkg/coredata"
)
// AccountRecord represents a single account from an access source or identity
// source. All fields are best-effort; sources populate what they can.
type AccountRecord struct {
Email string
FullName string
Role string // system role/permission (e.g. "Admin", "Viewer")
JobTitle string // HR job title / department (e.g. "Software Engineer")
Active bool
IsAdmin bool
MFAStatus coredata.MFAStatus
AuthMethod coredata.AccessEntryAuthMethod
AccountType coredata.AccessEntryAccountType
LastLogin *time.Time
CreatedAt *time.Time
ExternalID string // system-specific user ID
}
// maxPaginationPages is the upper bound on the number of pages a driver will
// fetch from an external API. This prevents infinite loops if an API returns
// a non-empty cursor on every response.
const maxPaginationPages = 500
// ErrPaginationLimitReached is returned when a driver exhausts the maximum
// number of pagination pages without reaching the end of the result set.
var ErrPaginationLimitReached = fmt.Errorf("pagination limit of %d pages reached", maxPaginationPages)
// Driver defines the interface for fetching accounts from an access or
// identity source. Each driver implementation corresponds to a specific
// system (e.g. Google Workspace, AWS IAM, Probo memberships, CSV).
//
// All sources in a campaign's scope return "who actually has access" data.
type Driver interface {
// ListAccounts returns all accounts from the source system.
ListAccounts(ctx context.Context) ([]AccountRecord, error)
}

View File

@@ -0,0 +1,286 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.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"
"strconv"
"time"
"go.gearno.de/kit/log"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/rfc5988"
)
// GitHubDriver fetches organization members from the GitHub REST API
// using a pre-authenticated HTTP client (Bearer token).
type GitHubDriver struct {
httpClient *http.Client
org string
logger *log.Logger
}
var _ Driver = (*GitHubDriver)(nil)
type githubMember struct {
Login string `json:"login"`
ID int64 `json:"id"`
Type string `json:"type"`
}
type githubMembership struct {
Role string `json:"role"`
State string `json:"state"`
}
type githubUserProfile struct {
Login string `json:"login"`
Name string `json:"name"`
Email string `json:"email"`
CreatedAt string `json:"created_at"`
Type string `json:"type"`
}
func NewGitHubDriver(httpClient *http.Client, org string, logger *log.Logger) *GitHubDriver {
return &GitHubDriver{
httpClient: httpClient,
org: org,
logger: logger,
}
}
func (d *GitHubDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error) {
members, err := d.fetchAllMembers(ctx)
if err != nil {
return nil, fmt.Errorf("cannot fetch github org members: %w", err)
}
no2FASet, err := d.fetchAll2FADisabledLogins(ctx)
if err != nil {
// If the 2FA list fetch fails (e.g. insufficient permissions),
// we still proceed but mark MFA as Unknown for all members.
no2FASet = nil
}
var records []AccountRecord
for _, m := range members {
membership, err := d.fetchMembership(ctx, m.Login)
if err != nil {
d.logger.WarnCtx(ctx, "cannot fetch github membership, skipping member",
log.Error(err),
)
continue
}
profile, err := d.fetchUserProfile(ctx, m.Login)
if err != nil {
d.logger.WarnCtx(ctx, "cannot fetch github user profile, skipping member",
log.Error(err),
)
continue
}
fullName := profile.Name
if fullName == "" {
fullName = m.Login
}
accountType := coredata.AccessEntryAccountTypeUser
if m.Type == "Bot" {
accountType = coredata.AccessEntryAccountTypeServiceAccount
}
mfaStatus := coredata.MFAStatusUnknown
if no2FASet != nil {
if no2FASet[m.Login] {
mfaStatus = coredata.MFAStatusDisabled
} else {
mfaStatus = coredata.MFAStatusEnabled
}
}
record := AccountRecord{
Email: profile.Email,
FullName: fullName,
Role: membership.Role,
Active: membership.State == "active",
IsAdmin: membership.Role == "admin",
MFAStatus: mfaStatus,
AuthMethod: coredata.AccessEntryAuthMethodUnknown,
AccountType: accountType,
ExternalID: strconv.FormatInt(m.ID, 10),
}
if profile.CreatedAt != "" {
if t, err := time.Parse(time.RFC3339, profile.CreatedAt); err == nil {
record.CreatedAt = &t
}
}
records = append(records, record)
}
return records, nil
}
func (d *GitHubDriver) fetchAllMembers(ctx context.Context) ([]githubMember, error) {
var members []githubMember
url := fmt.Sprintf(
"https://api.github.com/orgs/%s/members?per_page=100",
d.org,
)
for range maxPaginationPages {
page, nextURL, err := d.fetchMembersPage(ctx, url)
if err != nil {
return nil, err
}
members = append(members, page...)
if nextURL == "" {
return members, nil
}
url = nextURL
}
return nil, fmt.Errorf("cannot list all github members: %w", ErrPaginationLimitReached)
}
func (d *GitHubDriver) fetchMembersPage(ctx context.Context, url string) ([]githubMember, string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, "", fmt.Errorf("cannot create github members request: %w", err)
}
req.Header.Set("Accept", "application/vnd.github+json")
httpResp, err := d.httpClient.Do(req)
if err != nil {
return nil, "", fmt.Errorf("cannot execute github members request: %w", err)
}
defer func() {
_ = httpResp.Body.Close()
}()
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
return nil, "", fmt.Errorf("cannot fetch github members: unexpected status %d", httpResp.StatusCode)
}
var members []githubMember
if err := json.NewDecoder(httpResp.Body).Decode(&members); err != nil {
return nil, "", fmt.Errorf("cannot decode github members response: %w", err)
}
nextURL := rfc5988.FindByRel(httpResp.Header.Get("Link"), "next")
return members, nextURL, nil
}
func (d *GitHubDriver) fetchAll2FADisabledLogins(ctx context.Context) (map[string]bool, error) {
set := make(map[string]bool)
url := fmt.Sprintf(
"https://api.github.com/orgs/%s/members?filter=2fa_disabled&per_page=100",
d.org,
)
for range maxPaginationPages {
page, nextURL, err := d.fetchMembersPage(ctx, url)
if err != nil {
return nil, err
}
for _, m := range page {
set[m.Login] = true
}
if nextURL == "" {
return set, nil
}
url = nextURL
}
return nil, fmt.Errorf("cannot list all github 2fa-disabled members: %w", ErrPaginationLimitReached)
}
func (d *GitHubDriver) fetchMembership(ctx context.Context, login string) (*githubMembership, error) {
url := fmt.Sprintf(
"https://api.github.com/orgs/%s/memberships/%s",
d.org,
login,
)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("cannot create github membership request: %w", err)
}
req.Header.Set("Accept", "application/vnd.github+json")
httpResp, err := d.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("cannot execute github membership request: %w", err)
}
defer func() {
_ = httpResp.Body.Close()
}()
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
return nil, fmt.Errorf("cannot fetch github membership for %s: unexpected status %d", login, httpResp.StatusCode)
}
var membership githubMembership
if err := json.NewDecoder(httpResp.Body).Decode(&membership); err != nil {
return nil, fmt.Errorf("cannot decode github membership response: %w", err)
}
return &membership, nil
}
func (d *GitHubDriver) fetchUserProfile(ctx context.Context, login string) (*githubUserProfile, error) {
url := fmt.Sprintf("https://api.github.com/users/%s", login)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("cannot create github user profile request: %w", err)
}
req.Header.Set("Accept", "application/vnd.github+json")
httpResp, err := d.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("cannot execute github user profile request: %w", err)
}
defer func() {
_ = httpResp.Body.Close()
}()
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
return nil, fmt.Errorf("cannot fetch github user profile for %s: unexpected status %d", login, httpResp.StatusCode)
}
var profile githubUserProfile
if err := json.NewDecoder(httpResp.Body).Decode(&profile); err != nil {
return nil, fmt.Errorf("cannot decode github user profile response: %w", err)
}
return &profile, nil
}

View File

@@ -0,0 +1,47 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.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"
"go.gearno.de/kit/log"
)
func TestGitHubDriver(t *testing.T) {
t.Parallel()
rec := newRecorder(t, "testdata/github", "GITHUB_TOKEN")
client := newVCRClient(rec, bearerAuth(os.Getenv("GITHUB_TOKEN")))
org := os.Getenv("GITHUB_ORG")
if org == "" {
org = "acme-corp"
}
driver := NewGitHubDriver(client, org, log.NewLogger(log.WithName("test")))
records, err := driver.ListAccounts(context.Background())
require.NoError(t, err)
require.NotEmpty(t, records)
r := records[0]
assert.NotEmpty(t, r.FullName)
assert.NotEmpty(t, r.ExternalID)
assert.NotEmpty(t, r.Role)
}

View File

@@ -0,0 +1,157 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.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"
"fmt"
"net/http"
"time"
admin "google.golang.org/api/admin/directory/v1"
"google.golang.org/api/option"
"go.probo.inc/probo/pkg/coredata"
)
// GoogleWorkspaceDriver fetches user accounts from Google Workspace
// using the Admin Directory API via an OAuth2-authenticated HTTP client.
type GoogleWorkspaceDriver struct {
httpClient *http.Client
}
func NewGoogleWorkspaceDriver(httpClient *http.Client) *GoogleWorkspaceDriver {
return &GoogleWorkspaceDriver{
httpClient: &http.Client{
Transport: &retryRoundTripper{
next: httpClient.Transport,
maxRetries: 3,
},
},
}
}
// retryRoundTripper retries requests that receive 5xx or 429 responses
// with exponential backoff.
type retryRoundTripper struct {
next http.RoundTripper
maxRetries int
}
func (rt *retryRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
transport := rt.next
if transport == nil {
transport = http.DefaultTransport
}
var lastResp *http.Response
for attempt := range rt.maxRetries {
resp, err := transport.RoundTrip(req)
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusTooManyRequests && resp.StatusCode < 500 {
return resp, nil
}
_ = resp.Body.Close()
lastResp = resp
backoff := time.Duration(250*(1<<attempt)) * time.Millisecond
select {
case <-req.Context().Done():
return nil, req.Context().Err()
case <-time.After(backoff):
}
}
return lastResp, nil
}
func (d *GoogleWorkspaceDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error) {
adminService, err := admin.NewService(ctx, option.WithHTTPClient(d.httpClient))
if err != nil {
return nil, fmt.Errorf("cannot create google admin service: %w", err)
}
var records []AccountRecord
pageToken := ""
for range maxPaginationPages {
call := adminService.Users.List().
Customer("my_customer").
MaxResults(500).
Projection("full").
Context(ctx)
if pageToken != "" {
call = call.PageToken(pageToken)
}
resp, err := call.Do()
if err != nil {
return nil, fmt.Errorf("cannot list google workspace users: %w", err)
}
for _, u := range resp.Users {
rec := AccountRecord{
Email: u.PrimaryEmail,
FullName: u.Name.FullName,
Active: !u.Suspended && !u.Archived,
IsAdmin: u.IsAdmin,
ExternalID: u.Id,
MFAStatus: coredata.MFAStatusUnknown,
AuthMethod: coredata.AccessEntryAuthMethodSSO,
AccountType: coredata.AccessEntryAccountTypeUser,
}
if u.IsEnrolledIn2Sv {
rec.MFAStatus = coredata.MFAStatusEnabled
} else {
rec.MFAStatus = coredata.MFAStatusDisabled
}
if u.CreationTime != "" {
if t, err := time.Parse(time.RFC3339, u.CreationTime); err == nil {
rec.CreatedAt = &t
}
}
if u.LastLoginTime != "" {
if t, err := time.Parse(time.RFC3339, u.LastLoginTime); err == nil {
rec.LastLogin = &t
}
}
switch {
case u.IsAdmin:
rec.Role = "Super Admin"
case u.IsDelegatedAdmin:
rec.Role = "Delegated Admin"
default:
rec.Role = "User"
}
records = append(records, rec)
}
pageToken = resp.NextPageToken
if pageToken == "" {
return records, nil
}
}
return nil, fmt.Errorf("cannot list all google workspace accounts: %w", ErrPaginationLimitReached)
}

View File

@@ -0,0 +1,42 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.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 TestGoogleWorkspaceDriver(t *testing.T) {
t.Parallel()
rec := newRecorder(t, "testdata/google_workspace", "GOOGLE_WORKSPACE_TOKEN")
client := newVCRClient(rec, bearerAuth(os.Getenv("GOOGLE_WORKSPACE_TOKEN")))
driver := NewGoogleWorkspaceDriver(client)
records, err := driver.ListAccounts(context.Background())
require.NoError(t, err)
require.NotEmpty(t, records)
r := records[0]
assert.NotEmpty(t, r.Email)
assert.NotEmpty(t, r.FullName)
assert.NotEmpty(t, r.ExternalID)
assert.NotEmpty(t, r.Role)
}

View File

@@ -0,0 +1,190 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.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"
"strings"
"go.probo.inc/probo/pkg/coredata"
)
// HubSpotDriver fetches account users from HubSpot via OAuth2-authenticated
// REST requests.
type HubSpotDriver struct {
httpClient *http.Client
}
var _ Driver = (*HubSpotDriver)(nil)
type hubspotRolesResponse struct {
Results []struct {
ID string `json:"id"`
Name string `json:"name"`
} `json:"results"`
}
type hubspotUsersResponse struct {
Results []struct {
ID string `json:"id"`
Email string `json:"email"`
FirstName string `json:"firstName"`
LastName string `json:"lastName"`
RoleID string `json:"roleId"`
PrimaryTeamID string `json:"primaryTeamId"`
SuperAdmin bool `json:"superAdmin"`
} `json:"results"`
Paging *struct {
Next *struct {
After string `json:"after"`
} `json:"next"`
} `json:"paging"`
}
const (
hubspotUsersEndpoint = "https://api.hubapi.com/settings/v3/users"
hubspotRolesEndpoint = "https://api.hubapi.com/settings/v3/users/roles"
)
func NewHubSpotDriver(httpClient *http.Client) *HubSpotDriver {
return &HubSpotDriver{
httpClient: httpClient,
}
}
func (d *HubSpotDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error) {
roleMap, _ := d.fetchRoles(ctx)
var (
records []AccountRecord
after string
)
for range maxPaginationPages {
resp, err := d.fetchUsers(ctx, after)
if err != nil {
return nil, err
}
for _, u := range resp.Results {
role := "User"
if roleMap != nil && u.RoleID != "" {
if name, ok := roleMap[u.RoleID]; ok {
role = name
} else if u.SuperAdmin {
role = "Super Admin"
}
} else if u.SuperAdmin {
role = "Super Admin"
}
fullName := strings.TrimSpace(u.FirstName + " " + u.LastName)
record := AccountRecord{
Email: u.Email,
FullName: fullName,
Role: role,
Active: true,
IsAdmin: u.SuperAdmin,
ExternalID: u.ID,
MFAStatus: coredata.MFAStatusUnknown,
AuthMethod: coredata.AccessEntryAuthMethodUnknown,
AccountType: coredata.AccessEntryAccountTypeUser,
}
if record.Email != "" {
records = append(records, record)
}
}
if resp.Paging == nil || resp.Paging.Next == nil || resp.Paging.Next.After == "" {
return records, nil
}
after = resp.Paging.Next.After
}
return nil, fmt.Errorf("cannot list all hubspot accounts: %w", ErrPaginationLimitReached)
}
func (d *HubSpotDriver) fetchUsers(ctx context.Context, after string) (*hubspotUsersResponse, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, hubspotUsersEndpoint, nil)
if err != nil {
return nil, fmt.Errorf("cannot create hubspot users request: %w", err)
}
q := req.URL.Query()
q.Set("limit", "100")
if after != "" {
q.Set("after", after)
}
req.URL.RawQuery = q.Encode()
req.Header.Set("Accept", "application/json")
httpResp, err := d.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("cannot execute hubspot users request: %w", err)
}
defer func() {
_ = httpResp.Body.Close()
}()
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
return nil, fmt.Errorf("cannot fetch hubspot users: unexpected status %d", httpResp.StatusCode)
}
var resp hubspotUsersResponse
if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil {
return nil, fmt.Errorf("cannot decode hubspot users response: %w", err)
}
return &resp, nil
}
func (d *HubSpotDriver) fetchRoles(ctx context.Context) (map[string]string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, hubspotRolesEndpoint, nil)
if err != nil {
return nil, fmt.Errorf("cannot create hubspot roles request: %w", err)
}
req.Header.Set("Accept", "application/json")
httpResp, err := d.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("cannot execute hubspot roles request: %w", err)
}
defer func() {
_ = httpResp.Body.Close()
}()
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
return nil, fmt.Errorf("cannot fetch hubspot roles: unexpected status %d", httpResp.StatusCode)
}
var resp hubspotRolesResponse
if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil {
return nil, fmt.Errorf("cannot decode hubspot roles response: %w", err)
}
roleMap := make(map[string]string, len(resp.Results))
for _, r := range resp.Results {
roleMap[r.ID] = r.Name
}
return roleMap, nil
}

View File

@@ -0,0 +1,41 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.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 TestHubSpotDriver(t *testing.T) {
t.Parallel()
rec := newRecorder(t, "testdata/hubspot", "HUBSPOT_TOKEN")
client := newVCRClient(rec, bearerAuth(os.Getenv("HUBSPOT_TOKEN")))
driver := NewHubSpotDriver(client)
records, err := driver.ListAccounts(context.Background())
require.NoError(t, err)
require.NotEmpty(t, records)
r := records[0]
assert.NotEmpty(t, r.Email)
assert.NotEmpty(t, r.FullName)
assert.NotEmpty(t, r.ExternalID)
}

View File

@@ -0,0 +1,124 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.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"
"go.probo.inc/probo/pkg/coredata"
)
// IntercomDriver fetches workspace admins from Intercom via Bearer
// token-authenticated REST API requests.
type IntercomDriver struct {
httpClient *http.Client
}
var _ Driver = (*IntercomDriver)(nil)
type intercomAdminsResponse struct {
Type string `json:"type"`
Admins []struct {
Type string `json:"type"`
ID string `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
JobTitle string `json:"job_title"`
HasInboxSeat bool `json:"has_inbox_seat"`
} `json:"admins"`
}
const (
intercomAdminsEndpoint = "https://api.intercom.io/admins"
intercomAPIVersion = "2.11"
)
func NewIntercomDriver(httpClient *http.Client) *IntercomDriver {
return &IntercomDriver{
httpClient: httpClient,
}
}
func (d *IntercomDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error) {
resp, err := d.fetchAdmins(ctx)
if err != nil {
return nil, err
}
var records []AccountRecord
for _, a := range resp.Admins {
record := AccountRecord{
Email: a.Email,
FullName: a.Name,
Role: intercomRole(a.HasInboxSeat),
JobTitle: a.JobTitle,
Active: true,
IsAdmin: false, // Intercom API does not expose admin role information
ExternalID: a.ID,
MFAStatus: coredata.MFAStatusUnknown,
AuthMethod: coredata.AccessEntryAuthMethodUnknown,
AccountType: coredata.AccessEntryAccountTypeUser,
}
if record.Email != "" || record.FullName != "" {
records = append(records, record)
}
}
return records, nil
}
func (d *IntercomDriver) fetchAdmins(ctx context.Context) (*intercomAdminsResponse, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, intercomAdminsEndpoint, nil)
if err != nil {
return nil, fmt.Errorf("cannot create intercom admins request: %w", err)
}
req.Header.Set("Accept", "application/json")
req.Header.Set("Intercom-Version", intercomAPIVersion)
httpResp, err := d.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("cannot execute intercom admins request: %w", err)
}
defer func() {
_ = httpResp.Body.Close()
}()
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
return nil, fmt.Errorf("cannot fetch intercom admins: unexpected status %d", httpResp.StatusCode)
}
var resp intercomAdminsResponse
if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil {
return nil, fmt.Errorf("cannot decode intercom admins response: %w", err)
}
return &resp, nil
}
// intercomRole returns a role label based on whether the admin has an inbox
// seat. The Intercom API does not expose a proper role field, so this is the
// best approximation available: users with inbox seats are active agents,
// those without are limited/viewer users.
func intercomRole(hasInboxSeat bool) string {
if hasInboxSeat {
return "Agent"
}
return "Viewer"
}

View File

@@ -0,0 +1,41 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.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 TestIntercomDriver(t *testing.T) {
t.Parallel()
rec := newRecorder(t, "testdata/intercom", "INTERCOM_TOKEN")
client := newVCRClient(rec, bearerAuth(os.Getenv("INTERCOM_TOKEN")))
driver := NewIntercomDriver(client)
records, err := driver.ListAccounts(context.Background())
require.NoError(t, err)
require.NotEmpty(t, records)
r := records[0]
assert.NotEmpty(t, r.Email)
assert.NotEmpty(t, r.FullName)
assert.NotEmpty(t, r.ExternalID)
}

View File

@@ -0,0 +1,208 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.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"
"time"
"go.probo.inc/probo/pkg/coredata"
)
// LinearDriver fetches workspace users from Linear via OAuth2-authenticated
// GraphQL requests.
type LinearDriver struct {
httpClient *http.Client
}
var _ Driver = (*LinearDriver)(nil)
type linearUsersRequest struct {
Query string `json:"query"`
Variables linearUsersVariables `json:"variables"`
}
type linearUsersVariables struct {
After *string `json:"after"`
}
type linearUsersResponse struct {
Data struct {
Users struct {
Nodes []struct {
ID string `json:"id"`
Email string `json:"email"`
Name string `json:"name"`
Active bool `json:"active"`
Admin bool `json:"admin"`
Guest bool `json:"guest"`
LastSeen string `json:"lastSeen"`
CreatedAt string `json:"createdAt"`
} `json:"nodes"`
PageInfo struct {
HasNextPage bool `json:"hasNextPage"`
EndCursor string `json:"endCursor"`
} `json:"pageInfo"`
} `json:"users"`
} `json:"data"`
Errors []struct {
Message string `json:"message"`
} `json:"errors"`
}
const linearGraphQLEndpoint = "https://api.linear.app/graphql"
func NewLinearDriver(httpClient *http.Client) *LinearDriver {
return &LinearDriver{
httpClient: httpClient,
}
}
func (d *LinearDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error) {
var (
records []AccountRecord
after *string
)
for range maxPaginationPages {
resp, err := d.queryUsers(ctx, after)
if err != nil {
return nil, err
}
for _, u := range resp.Data.Users.Nodes {
accountType := coredata.AccessEntryAccountTypeUser
if strings.HasSuffix(u.Email, ".linear.app") {
accountType = coredata.AccessEntryAccountTypeServiceAccount
}
record := AccountRecord{
Email: u.Email,
FullName: u.Name,
Role: linearRole(u.Admin, u.Guest),
Active: u.Active,
IsAdmin: u.Admin,
ExternalID: u.ID,
MFAStatus: coredata.MFAStatusUnknown,
AuthMethod: coredata.AccessEntryAuthMethodUnknown,
AccountType: accountType,
}
if u.LastSeen != "" {
if t, err := time.Parse(time.RFC3339, u.LastSeen); err == nil {
record.LastLogin = &t
}
}
if u.CreatedAt != "" {
if t, err := time.Parse(time.RFC3339, u.CreatedAt); err == nil {
record.CreatedAt = &t
}
}
if record.Email != "" {
records = append(records, record)
}
}
if !resp.Data.Users.PageInfo.HasNextPage || resp.Data.Users.PageInfo.EndCursor == "" {
return records, nil
}
nextCursor := resp.Data.Users.PageInfo.EndCursor
after = &nextCursor
}
return nil, fmt.Errorf("cannot list all linear accounts: %w", ErrPaginationLimitReached)
}
func (d *LinearDriver) queryUsers(ctx context.Context, after *string) (*linearUsersResponse, error) {
const query = `
query AccessReviewLinearUsers($after: String) {
users(first: 100, after: $after) {
nodes {
id
email
name
active
admin
guest
lastSeen
createdAt
}
pageInfo {
hasNextPage
endCursor
}
}
}
`
body := linearUsersRequest{
Query: query,
Variables: linearUsersVariables{
After: after,
},
}
payload, err := json.Marshal(body)
if err != nil {
return nil, fmt.Errorf("cannot marshal linear users query: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, linearGraphQLEndpoint, bytes.NewReader(payload))
if err != nil {
return nil, fmt.Errorf("cannot create linear users request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
httpResp, err := d.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("cannot execute linear users request: %w", err)
}
defer func() {
_ = httpResp.Body.Close()
}()
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
return nil, fmt.Errorf("cannot fetch linear users: unexpected status %d", httpResp.StatusCode)
}
var resp linearUsersResponse
if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil {
return nil, fmt.Errorf("cannot decode linear users response: %w", err)
}
if len(resp.Errors) > 0 {
return nil, fmt.Errorf("linear graphql error: %s", resp.Errors[0].Message)
}
return &resp, nil
}
func linearRole(admin, guest bool) string {
switch {
case admin:
return "Admin"
case guest:
return "Guest"
default:
return "Member"
}
}

View File

@@ -0,0 +1,42 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.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 TestLinearDriver(t *testing.T) {
t.Parallel()
rec := newRecorder(t, "testdata/linear", "LINEAR_TOKEN")
client := newVCRClient(rec, os.Getenv("LINEAR_TOKEN"))
driver := NewLinearDriver(client)
records, err := driver.ListAccounts(context.Background())
require.NoError(t, err)
require.NotEmpty(t, records)
r := records[0]
assert.NotEmpty(t, r.Email)
assert.NotEmpty(t, r.FullName)
assert.NotEmpty(t, r.ExternalID)
assert.NotEmpty(t, r.Role)
}

View File

@@ -0,0 +1,590 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.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"
admin "google.golang.org/api/admin/directory/v1"
"google.golang.org/api/option"
"go.probo.inc/probo/pkg/coredata"
)
// NameResolver fetches the human-readable instance name from a provider
// (e.g. Slack workspace name, Google Workspace domain).
type NameResolver interface {
ResolveInstanceName(ctx context.Context) (string, error)
}
var providerDisplayNames = map[coredata.ConnectorProvider]string{
coredata.ConnectorProviderSlack: "Slack",
coredata.ConnectorProviderGoogleWorkspace: "Google Workspace",
coredata.ConnectorProviderLinear: "Linear",
coredata.ConnectorProviderOnePassword: "1Password",
coredata.ConnectorProviderHubSpot: "HubSpot",
coredata.ConnectorProviderDocuSign: "DocuSign",
coredata.ConnectorProviderNotion: "Notion",
coredata.ConnectorProviderBrex: "Brex",
coredata.ConnectorProviderTally: "Tally",
coredata.ConnectorProviderCloudflare: "Cloudflare",
coredata.ConnectorProviderOpenAI: "OpenAI",
coredata.ConnectorProviderSentry: "Sentry",
coredata.ConnectorProviderSupabase: "Supabase",
coredata.ConnectorProviderGitHub: "GitHub",
coredata.ConnectorProviderIntercom: "Intercom",
coredata.ConnectorProviderResend: "Resend",
}
// ProviderDisplayName returns the human-readable label for a connector provider.
func ProviderDisplayName(provider coredata.ConnectorProvider) string {
if name, ok := providerDisplayNames[provider]; ok {
return name
}
return string(provider)
}
// slackNameResolver resolves the Slack workspace name via auth.test.
type slackNameResolver struct {
httpClient *http.Client
}
func NewSlackNameResolver(httpClient *http.Client) NameResolver {
return &slackNameResolver{httpClient: httpClient}
}
func (r *slackNameResolver) ResolveInstanceName(ctx context.Context) (string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://slack.com/api/auth.test", nil)
if err != nil {
return "", fmt.Errorf("cannot create slack auth.test request: %w", err)
}
httpResp, err := r.httpClient.Do(req)
if err != nil {
return "", fmt.Errorf("cannot execute slack auth.test request: %w", err)
}
defer func() { _ = httpResp.Body.Close() }()
var resp struct {
OK bool `json:"ok"`
Team string `json:"team"`
}
if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil {
return "", fmt.Errorf("cannot decode slack auth.test response: %w", err)
}
if !resp.OK {
return "", fmt.Errorf("slack auth.test returned ok=false")
}
return resp.Team, nil
}
// googleWorkspaceNameResolver resolves the Google Workspace primary domain.
type googleWorkspaceNameResolver struct {
httpClient *http.Client
}
func NewGoogleWorkspaceNameResolver(httpClient *http.Client) NameResolver {
return &googleWorkspaceNameResolver{httpClient: httpClient}
}
func (r *googleWorkspaceNameResolver) ResolveInstanceName(ctx context.Context) (string, error) {
adminService, err := admin.NewService(ctx, option.WithHTTPClient(r.httpClient))
if err != nil {
return "", fmt.Errorf("cannot create google admin service: %w", err)
}
customer, err := adminService.Customers.Get("my_customer").Context(ctx).Do()
if err != nil {
return "", fmt.Errorf("cannot fetch google workspace customer: %w", err)
}
return customer.CustomerDomain, nil
}
// linearNameResolver resolves the Linear organization name via GraphQL.
type linearNameResolver struct {
httpClient *http.Client
}
func NewLinearNameResolver(httpClient *http.Client) NameResolver {
return &linearNameResolver{httpClient: httpClient}
}
func (r *linearNameResolver) ResolveInstanceName(ctx context.Context) (string, error) {
body := struct {
Query string `json:"query"`
}{
Query: `{ organization { name } }`,
}
payload, err := json.Marshal(body)
if err != nil {
return "", fmt.Errorf("cannot marshal linear organization query: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, linearGraphQLEndpoint, bytes.NewReader(payload))
if err != nil {
return "", fmt.Errorf("cannot create linear organization request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
httpResp, err := r.httpClient.Do(req)
if err != nil {
return "", fmt.Errorf("cannot execute linear organization request: %w", err)
}
defer func() { _ = httpResp.Body.Close() }()
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
return "", fmt.Errorf("cannot fetch linear organization: unexpected status %d", httpResp.StatusCode)
}
var resp struct {
Data struct {
Organization struct {
Name string `json:"name"`
} `json:"organization"`
} `json:"data"`
Errors []struct {
Message string `json:"message"`
} `json:"errors"`
}
if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil {
return "", fmt.Errorf("cannot decode linear organization response: %w", err)
}
if len(resp.Errors) > 0 {
return "", fmt.Errorf("linear graphql error: %s", resp.Errors[0].Message)
}
return resp.Data.Organization.Name, nil
}
// cloudflareNameResolver resolves the Cloudflare account name.
type cloudflareNameResolver struct {
httpClient *http.Client
}
func NewCloudflareNameResolver(httpClient *http.Client) NameResolver {
return &cloudflareNameResolver{httpClient: httpClient}
}
func (r *cloudflareNameResolver) ResolveInstanceName(ctx context.Context) (string, error) {
req, err := http.NewRequestWithContext(
ctx,
http.MethodGet,
"https://api.cloudflare.com/client/v4/accounts?page=1&per_page=1",
nil,
)
if err != nil {
return "", fmt.Errorf("cannot create cloudflare accounts request: %w", err)
}
req.Header.Set("Accept", "application/json")
httpResp, err := r.httpClient.Do(req)
if err != nil {
return "", fmt.Errorf("cannot execute cloudflare accounts request: %w", err)
}
defer func() { _ = httpResp.Body.Close() }()
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
return "", fmt.Errorf("cannot fetch cloudflare accounts: unexpected status %d", httpResp.StatusCode)
}
var resp struct {
Result []struct {
Name string `json:"name"`
} `json:"result"`
}
if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil {
return "", fmt.Errorf("cannot decode cloudflare accounts response: %w", err)
}
if len(resp.Result) == 0 {
return "", fmt.Errorf("no cloudflare accounts found")
}
return resp.Result[0].Name, nil
}
// brexNameResolver resolves the Brex company name.
type brexNameResolver struct {
httpClient *http.Client
}
func NewBrexNameResolver(httpClient *http.Client) NameResolver {
return &brexNameResolver{httpClient: httpClient}
}
func (r *brexNameResolver) ResolveInstanceName(ctx context.Context) (string, error) {
req, err := http.NewRequestWithContext(
ctx,
http.MethodGet,
"https://platform.brexapis.com/v2/company",
nil,
)
if err != nil {
return "", fmt.Errorf("cannot create brex company request: %w", err)
}
req.Header.Set("Accept", "application/json")
httpResp, err := r.httpClient.Do(req)
if err != nil {
return "", fmt.Errorf("cannot execute brex company request: %w", err)
}
defer func() { _ = httpResp.Body.Close() }()
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
return "", fmt.Errorf("cannot fetch brex company: unexpected status %d", httpResp.StatusCode)
}
var resp struct {
LegalName string `json:"legal_name"`
}
if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil {
return "", fmt.Errorf("cannot decode brex company response: %w", err)
}
return resp.LegalName, nil
}
// tallyNameResolver resolves the Tally organization name.
type tallyNameResolver struct {
httpClient *http.Client
organizationID string
}
func NewTallyNameResolver(httpClient *http.Client, organizationID string) NameResolver {
return &tallyNameResolver{
httpClient: httpClient,
organizationID: organizationID,
}
}
func (r *tallyNameResolver) ResolveInstanceName(ctx context.Context) (string, error) {
url := fmt.Sprintf("https://api.tally.so/organizations/%s", r.organizationID)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return "", fmt.Errorf("cannot create tally organization request: %w", err)
}
req.Header.Set("Accept", "application/json")
httpResp, err := r.httpClient.Do(req)
if err != nil {
return "", fmt.Errorf("cannot execute tally organization request: %w", err)
}
defer func() { _ = httpResp.Body.Close() }()
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
return "", fmt.Errorf("cannot fetch tally organization: unexpected status %d", httpResp.StatusCode)
}
var resp struct {
Name string `json:"name"`
}
if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil {
return "", fmt.Errorf("cannot decode tally organization response: %w", err)
}
return resp.Name, nil
}
// hubspotNameResolver resolves the HubSpot account name.
type hubspotNameResolver struct {
httpClient *http.Client
}
func NewHubSpotNameResolver(httpClient *http.Client) NameResolver {
return &hubspotNameResolver{httpClient: httpClient}
}
func (r *hubspotNameResolver) ResolveInstanceName(ctx context.Context) (string, error) {
req, err := http.NewRequestWithContext(
ctx,
http.MethodGet,
"https://api.hubapi.com/account-info/v3/details",
nil,
)
if err != nil {
return "", fmt.Errorf("cannot create hubspot account-info request: %w", err)
}
req.Header.Set("Accept", "application/json")
httpResp, err := r.httpClient.Do(req)
if err != nil {
return "", fmt.Errorf("cannot execute hubspot account-info request: %w", err)
}
defer func() { _ = httpResp.Body.Close() }()
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
return "", fmt.Errorf("cannot fetch hubspot account info: unexpected status %d", httpResp.StatusCode)
}
var resp struct {
PortalID int `json:"portalId"`
AccountName string `json:"accountName"`
}
if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil {
return "", fmt.Errorf("cannot decode hubspot account-info response: %w", err)
}
return resp.AccountName, nil
}
// docusignNameResolver resolves the DocuSign account name from userinfo.
type docusignNameResolver struct {
httpClient *http.Client
}
func NewDocuSignNameResolver(httpClient *http.Client) NameResolver {
return &docusignNameResolver{httpClient: httpClient}
}
func (r *docusignNameResolver) ResolveInstanceName(ctx context.Context) (string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, docusignUserInfoEndpoint, nil)
if err != nil {
return "", fmt.Errorf("cannot create docusign userinfo request: %w", err)
}
req.Header.Set("Accept", "application/json")
httpResp, err := r.httpClient.Do(req)
if err != nil {
return "", fmt.Errorf("cannot execute docusign userinfo request: %w", err)
}
defer func() { _ = httpResp.Body.Close() }()
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
return "", fmt.Errorf("cannot fetch docusign userinfo: unexpected status %d", httpResp.StatusCode)
}
var resp struct {
Accounts []struct {
AccountName string `json:"account_name"`
IsDefault bool `json:"is_default"`
} `json:"accounts"`
}
if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil {
return "", fmt.Errorf("cannot decode docusign userinfo response: %w", err)
}
for _, account := range resp.Accounts {
if account.IsDefault {
return account.AccountName, nil
}
}
if len(resp.Accounts) > 0 {
return resp.Accounts[0].AccountName, nil
}
return "", nil
}
// openaiNameResolver resolves the OpenAI organization name.
type openaiNameResolver struct {
httpClient *http.Client
}
func NewOpenAINameResolver(httpClient *http.Client) NameResolver {
return &openaiNameResolver{httpClient: httpClient}
}
func (r *openaiNameResolver) ResolveInstanceName(ctx context.Context) (string, error) {
req, err := http.NewRequestWithContext(
ctx,
http.MethodGet,
"https://api.openai.com/v1/organization",
nil,
)
if err != nil {
return "", fmt.Errorf("cannot create openai organization request: %w", err)
}
req.Header.Set("Accept", "application/json")
httpResp, err := r.httpClient.Do(req)
if err != nil {
return "", fmt.Errorf("cannot execute openai organization request: %w", err)
}
defer func() { _ = httpResp.Body.Close() }()
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
// OpenAI may not support this endpoint for all token types.
return "", nil
}
var resp struct {
Name string `json:"name"`
}
if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil {
return "", fmt.Errorf("cannot decode openai organization response: %w", err)
}
return resp.Name, nil
}
// sentryNameResolver resolves the Sentry organization name.
type sentryNameResolver struct {
httpClient *http.Client
orgSlug string
}
func NewSentryNameResolver(httpClient *http.Client, orgSlug string) NameResolver {
return &sentryNameResolver{httpClient: httpClient, orgSlug: orgSlug}
}
func (r *sentryNameResolver) ResolveInstanceName(ctx context.Context) (string, error) {
if r.orgSlug == "" {
return "", nil
}
url := fmt.Sprintf("https://sentry.io/api/0/organizations/%s/", r.orgSlug)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return "", fmt.Errorf("cannot create sentry organization request: %w", err)
}
req.Header.Set("Accept", "application/json")
httpResp, err := r.httpClient.Do(req)
if err != nil {
return "", fmt.Errorf("cannot execute sentry organization request: %w", err)
}
defer func() { _ = httpResp.Body.Close() }()
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
return "", fmt.Errorf("cannot fetch sentry organization: unexpected status %d", httpResp.StatusCode)
}
var resp struct {
Name string `json:"name"`
}
if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil {
return "", fmt.Errorf("cannot decode sentry organization response: %w", err)
}
return resp.Name, nil
}
// githubNameResolver resolves the GitHub organization name.
type githubNameResolver struct {
httpClient *http.Client
org string
}
func NewGitHubNameResolver(httpClient *http.Client, org string) NameResolver {
return &githubNameResolver{httpClient: httpClient, org: org}
}
func (r *githubNameResolver) ResolveInstanceName(ctx context.Context) (string, error) {
url := fmt.Sprintf("https://api.github.com/orgs/%s", r.org)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return "", fmt.Errorf("cannot create github organization request: %w", err)
}
req.Header.Set("Accept", "application/vnd.github+json")
httpResp, err := r.httpClient.Do(req)
if err != nil {
return "", fmt.Errorf("cannot execute github organization request: %w", err)
}
defer func() { _ = httpResp.Body.Close() }()
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
return "", fmt.Errorf("cannot fetch github organization: unexpected status %d", httpResp.StatusCode)
}
var resp struct {
Name string `json:"name"`
}
if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil {
return "", fmt.Errorf("cannot decode github organization response: %w", err)
}
if resp.Name == "" {
return r.org, nil
}
return resp.Name, nil
}
// supabaseNameResolver returns the Supabase organization slug as the name.
type supabaseNameResolver struct {
orgSlug string
}
func NewSupabaseNameResolver(orgSlug string) NameResolver {
return &supabaseNameResolver{orgSlug: orgSlug}
}
func (r *supabaseNameResolver) ResolveInstanceName(_ context.Context) (string, error) {
return r.orgSlug, nil
}
// intercomNameResolver resolves the Intercom app name.
type intercomNameResolver struct {
httpClient *http.Client
}
func NewIntercomNameResolver(httpClient *http.Client) NameResolver {
return &intercomNameResolver{httpClient: httpClient}
}
func (r *intercomNameResolver) ResolveInstanceName(ctx context.Context) (string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.intercom.io/me", nil)
if err != nil {
return "", fmt.Errorf("cannot create intercom me request: %w", err)
}
req.Header.Set("Accept", "application/json")
req.Header.Set("Intercom-Version", "2.11")
httpResp, err := r.httpClient.Do(req)
if err != nil {
return "", fmt.Errorf("cannot execute intercom me request: %w", err)
}
defer func() { _ = httpResp.Body.Close() }()
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
return "", nil
}
var resp struct {
App struct {
Name string `json:"name"`
} `json:"app"`
}
if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil {
return "", fmt.Errorf("cannot decode intercom me response: %w", err)
}
return resp.App.Name, nil
}
// resendNameResolver returns a static name for Resend.
type resendNameResolver struct{}
func NewResendNameResolver() NameResolver {
return &resendNameResolver{}
}
func (r *resendNameResolver) ResolveInstanceName(_ context.Context) (string, error) {
return "Resend", nil
}

View File

@@ -0,0 +1,141 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.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"
"go.probo.inc/probo/pkg/coredata"
)
type NotionDriver struct {
httpClient *http.Client
}
var _ Driver = (*NotionDriver)(nil)
type notionUsersResponse struct {
Results []struct {
ID string `json:"id"`
Type string `json:"type"`
Name string `json:"name"`
Person struct {
Email string `json:"email"`
} `json:"person"`
Bot struct{} `json:"bot"`
} `json:"results"`
HasMore bool `json:"has_more"`
NextCursor string `json:"next_cursor"`
}
const (
notionUsersEndpoint = "https://api.notion.com/v1/users"
notionAPIVersion = "2022-06-28"
)
func NewNotionDriver(httpClient *http.Client) *NotionDriver {
return &NotionDriver{
httpClient: httpClient,
}
}
func (d *NotionDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error) {
var (
records []AccountRecord
startCursor *string
)
for range maxPaginationPages {
resp, err := d.queryUsers(ctx, startCursor)
if err != nil {
return nil, err
}
for _, u := range resp.Results {
accountType := coredata.AccessEntryAccountTypeUser
if u.Type == "bot" {
accountType = coredata.AccessEntryAccountTypeServiceAccount
}
var email string
if u.Type == "person" {
email = u.Person.Email
}
record := AccountRecord{
Email: email,
FullName: u.Name,
Role: "Member",
Active: true,
IsAdmin: false,
ExternalID: u.ID,
MFAStatus: coredata.MFAStatusUnknown,
AuthMethod: coredata.AccessEntryAuthMethodUnknown,
AccountType: accountType,
}
if record.Email != "" || record.FullName != "" {
records = append(records, record)
}
}
if !resp.HasMore || resp.NextCursor == "" {
return records, nil
}
nextCursor := resp.NextCursor
startCursor = &nextCursor
}
return nil, fmt.Errorf("cannot list all notion accounts: %w", ErrPaginationLimitReached)
}
func (d *NotionDriver) queryUsers(ctx context.Context, startCursor *string) (*notionUsersResponse, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, notionUsersEndpoint, nil)
if err != nil {
return nil, fmt.Errorf("cannot create notion users request: %w", err)
}
req.Header.Set("Notion-Version", notionAPIVersion)
req.Header.Set("Accept", "application/json")
q := req.URL.Query()
q.Set("page_size", "100")
if startCursor != nil {
q.Set("start_cursor", *startCursor)
}
req.URL.RawQuery = q.Encode()
httpResp, err := d.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("cannot execute notion users request: %w", err)
}
defer func() {
_ = httpResp.Body.Close()
}()
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
return nil, fmt.Errorf("cannot fetch notion users: unexpected status %d", httpResp.StatusCode)
}
var resp notionUsersResponse
if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil {
return nil, fmt.Errorf("cannot decode notion users response: %w", err)
}
return &resp, nil
}

View File

@@ -0,0 +1,40 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.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 TestNotionDriver(t *testing.T) {
t.Parallel()
rec := newRecorder(t, "testdata/notion", "NOTION_TOKEN")
client := newVCRClient(rec, bearerAuth(os.Getenv("NOTION_TOKEN")))
driver := NewNotionDriver(client)
records, err := driver.ListAccounts(context.Background())
require.NoError(t, err)
require.NotEmpty(t, records)
r := records[0]
assert.NotEmpty(t, r.FullName)
assert.NotEmpty(t, r.ExternalID)
}

View File

@@ -0,0 +1,172 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.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"
"time"
"go.probo.inc/probo/pkg/coredata"
)
// OnePasswordDriver fetches user accounts from a 1Password SCIM bridge.
type OnePasswordDriver struct {
httpClient *http.Client
baseURL string
}
var _ Driver = (*OnePasswordDriver)(nil)
type onePasswordSCIMListResponse struct {
TotalResults int `json:"totalResults"`
StartIndex int `json:"startIndex"`
ItemsPerPage int `json:"itemsPerPage"`
Resources []onePasswordSCIMUser `json:"Resources"`
}
type onePasswordSCIMUser struct {
ID string `json:"id"`
UserName string `json:"userName"`
DisplayName string `json:"displayName"`
Title string `json:"title"`
Active bool `json:"active"`
Name struct {
Formatted string `json:"formatted"`
GivenName string `json:"givenName"`
FamilyName string `json:"familyName"`
} `json:"name"`
Emails []struct {
Value string `json:"value"`
Primary bool `json:"primary"`
} `json:"emails"`
Meta struct {
Created string `json:"created"`
LastModified string `json:"lastModified"`
} `json:"meta"`
}
func NewOnePasswordDriver(httpClient *http.Client, baseURL string) *OnePasswordDriver {
return &OnePasswordDriver{
httpClient: httpClient,
baseURL: baseURL,
}
}
func (d *OnePasswordDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error) {
var records []AccountRecord
startIndex := 1
for range maxPaginationPages {
resp, err := d.queryUsers(ctx, startIndex)
if err != nil {
return nil, err
}
for _, u := range resp.Resources {
email := u.UserName
if email == "" {
for _, e := range u.Emails {
if e.Primary {
email = e.Value
break
}
}
}
record := AccountRecord{
Email: email,
FullName: u.DisplayName,
Active: u.Active,
ExternalID: u.ID,
MFAStatus: coredata.MFAStatusUnknown,
AuthMethod: coredata.AccessEntryAuthMethodUnknown,
AccountType: coredata.AccessEntryAccountTypeUser,
}
if record.FullName == "" && u.Name.Formatted != "" {
record.FullName = u.Name.Formatted
}
if record.FullName == "" && (u.Name.GivenName != "" || u.Name.FamilyName != "") {
record.FullName = u.Name.GivenName + " " + u.Name.FamilyName
}
if u.Title != "" {
record.JobTitle = u.Title
}
if u.Meta.Created != "" {
if t, err := time.Parse(time.RFC3339, u.Meta.Created); err == nil {
record.CreatedAt = &t
}
}
// Note: SCIM Meta.LastModified is the profile update time, not
// the last login time, so we intentionally do not map it.
if email != "" {
records = append(records, record)
}
}
if len(resp.Resources) == 0 || resp.ItemsPerPage <= 0 || startIndex+resp.ItemsPerPage > resp.TotalResults {
return records, nil
}
startIndex += resp.ItemsPerPage
}
return nil, fmt.Errorf("cannot list all 1password accounts: %w", ErrPaginationLimitReached)
}
func (d *OnePasswordDriver) queryUsers(ctx context.Context, startIndex int) (*onePasswordSCIMListResponse, error) {
u, err := url.Parse(d.baseURL)
if err != nil {
return nil, fmt.Errorf("cannot parse 1password base url: %w", err)
}
u = u.JoinPath("scim", "v2", "Users")
q := u.Query()
q.Set("startIndex", strconv.Itoa(startIndex))
q.Set("count", "100")
u.RawQuery = q.Encode()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
if err != nil {
return nil, fmt.Errorf("cannot create 1password users request: %w", err)
}
req.Header.Set("Accept", "application/scim+json")
httpResp, err := d.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("cannot execute 1password users request: %w", err)
}
defer func() {
_ = httpResp.Body.Close()
}()
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
return nil, fmt.Errorf("cannot fetch 1password users: unexpected status %d", httpResp.StatusCode)
}
var resp onePasswordSCIMListResponse
if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil {
return nil, fmt.Errorf("cannot decode 1password users response: %w", err)
}
return &resp, nil
}

View File

@@ -0,0 +1,46 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.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 TestOnePasswordDriver(t *testing.T) {
t.Parallel()
rec := newRecorder(t, "testdata/onepassword", "ONEPASSWORD_TOKEN")
client := newVCRClient(rec, bearerAuth(os.Getenv("ONEPASSWORD_TOKEN")))
scimURL := os.Getenv("ONEPASSWORD_SCIM_URL")
if scimURL == "" {
scimURL = "https://scim.example.com"
}
driver := NewOnePasswordDriver(client, scimURL)
records, err := driver.ListAccounts(context.Background())
require.NoError(t, err)
require.NotEmpty(t, records)
r := records[0]
assert.NotEmpty(t, r.FullName)
assert.NotEmpty(t, r.ExternalID)
}

View File

@@ -0,0 +1,155 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.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"
"time"
"go.probo.inc/probo/pkg/coredata"
)
// OnePasswordUsersAPIDriver fetches user accounts from the 1Password
// Users API (v1beta1). This is distinct from the SCIM-based
// OnePasswordDriver and uses the native 1Password API with
// token-based pagination.
type OnePasswordUsersAPIDriver struct {
httpClient *http.Client
baseURL string
accountID string
}
var _ Driver = (*OnePasswordUsersAPIDriver)(nil)
type onePasswordUsersAPIResponse struct {
Users []onePasswordUsersAPIUser `json:"users"`
NextPageToken string `json:"next_page_token"`
}
type onePasswordUsersAPIUser struct {
ID string `json:"id"`
Email string `json:"email"`
DisplayName string `json:"display_name"`
State string `json:"state"`
CreateTime string `json:"create_time"`
Path string `json:"path"`
}
func NewOnePasswordUsersAPIDriver(httpClient *http.Client, accountID string, region string) *OnePasswordUsersAPIDriver {
return &OnePasswordUsersAPIDriver{
httpClient: httpClient,
baseURL: onePasswordBaseURL(region),
accountID: accountID,
}
}
func onePasswordBaseURL(region string) string {
switch region {
case "CA", "ca":
return "https://api.1password.ca"
case "EU", "eu":
return "https://api.1password.eu"
default:
return "https://api.1password.com"
}
}
func (d *OnePasswordUsersAPIDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error) {
var (
records []AccountRecord
pageToken string
)
for range maxPaginationPages {
resp, err := d.queryUsers(ctx, pageToken)
if err != nil {
return nil, err
}
for _, u := range resp.Users {
record := AccountRecord{
Email: u.Email,
FullName: u.DisplayName,
Active: u.State == "ACTIVE",
ExternalID: u.ID,
MFAStatus: coredata.MFAStatusUnknown,
AuthMethod: coredata.AccessEntryAuthMethodUnknown,
AccountType: coredata.AccessEntryAccountTypeUser,
}
if u.CreateTime != "" {
if t, err := time.Parse(time.RFC3339, u.CreateTime); err == nil {
record.CreatedAt = &t
}
}
if record.Email != "" {
records = append(records, record)
}
}
if resp.NextPageToken == "" {
return records, nil
}
pageToken = resp.NextPageToken
}
return nil, fmt.Errorf("cannot list all 1password users api accounts: %w", ErrPaginationLimitReached)
}
func (d *OnePasswordUsersAPIDriver) queryUsers(ctx context.Context, pageToken string) (*onePasswordUsersAPIResponse, error) {
u, err := url.Parse(d.baseURL)
if err != nil {
return nil, fmt.Errorf("cannot parse 1password users api base url: %w", err)
}
u = u.JoinPath("v1beta1", "accounts", d.accountID, "users")
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
if err != nil {
return nil, fmt.Errorf("cannot create 1password users api request: %w", err)
}
q := req.URL.Query()
q.Set("max_page_size", "100")
if pageToken != "" {
q.Set("page_token", pageToken)
}
req.URL.RawQuery = q.Encode()
req.Header.Set("Accept", "application/json")
httpResp, err := d.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("cannot execute 1password users api request: %w", err)
}
defer func() {
_ = httpResp.Body.Close()
}()
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
return nil, fmt.Errorf("cannot fetch 1password users api: unexpected status %d", httpResp.StatusCode)
}
var resp onePasswordUsersAPIResponse
if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil {
return nil, fmt.Errorf("cannot decode 1password users api response: %w", err)
}
return &resp, nil
}

View File

@@ -0,0 +1,142 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.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"
"time"
"go.probo.inc/probo/pkg/coredata"
)
type OpenAIDriver struct {
httpClient *http.Client
}
var _ Driver = (*OpenAIDriver)(nil)
type openaiUsersResponse struct {
Data []struct {
ID string `json:"id"`
Email string `json:"email"`
Name string `json:"name"`
Role string `json:"role"`
AddedAt int64 `json:"added_at"`
Disabled bool `json:"disabled"`
} `json:"data"`
HasMore bool `json:"has_more"`
LastID string `json:"last_id"`
}
const openaiUsersEndpoint = "https://api.openai.com/v1/organization/users"
func NewOpenAIDriver(httpClient *http.Client) *OpenAIDriver {
return &OpenAIDriver{
httpClient: httpClient,
}
}
func (d *OpenAIDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error) {
var (
records []AccountRecord
after string
)
for range maxPaginationPages {
resp, err := d.fetchUsers(ctx, after)
if err != nil {
return nil, err
}
for _, u := range resp.Data {
record := AccountRecord{
Email: u.Email,
FullName: u.Name,
Role: openaiRole(u.Role),
Active: !u.Disabled,
IsAdmin: u.Role == "owner",
ExternalID: u.ID,
MFAStatus: coredata.MFAStatusUnknown,
AuthMethod: coredata.AccessEntryAuthMethodUnknown,
AccountType: coredata.AccessEntryAccountTypeUser,
}
if u.AddedAt != 0 {
t := time.Unix(u.AddedAt, 0)
record.CreatedAt = &t
}
if record.Email != "" {
records = append(records, record)
}
}
if !resp.HasMore || resp.LastID == "" {
return records, nil
}
after = resp.LastID
}
return nil, fmt.Errorf("cannot list all openai accounts: %w", ErrPaginationLimitReached)
}
func (d *OpenAIDriver) fetchUsers(ctx context.Context, after string) (*openaiUsersResponse, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, openaiUsersEndpoint, nil)
if err != nil {
return nil, fmt.Errorf("cannot create openai users request: %w", err)
}
q := req.URL.Query()
q.Set("limit", "100")
if after != "" {
q.Set("after", after)
}
req.URL.RawQuery = q.Encode()
req.Header.Set("Accept", "application/json")
httpResp, err := d.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("cannot execute openai users request: %w", err)
}
defer func() {
_ = httpResp.Body.Close()
}()
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
return nil, fmt.Errorf("cannot fetch openai users: unexpected status %d", httpResp.StatusCode)
}
var resp openaiUsersResponse
if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil {
return nil, fmt.Errorf("cannot decode openai users response: %w", err)
}
return &resp, nil
}
func openaiRole(role string) string {
switch role {
case "owner":
return "Owner"
case "reader":
return "Reader"
default:
return "Member"
}
}

View File

@@ -0,0 +1,42 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.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 TestOpenAIDriver(t *testing.T) {
t.Parallel()
rec := newRecorder(t, "testdata/openai", "OPENAI_ADMIN_TOKEN")
client := newVCRClient(rec, bearerAuth(os.Getenv("OPENAI_ADMIN_TOKEN")))
driver := NewOpenAIDriver(client)
records, err := driver.ListAccounts(context.Background())
require.NoError(t, err)
require.NotEmpty(t, records)
r := records[0]
assert.NotEmpty(t, r.Email)
assert.NotEmpty(t, r.FullName)
assert.NotEmpty(t, r.ExternalID)
assert.NotEmpty(t, r.Role)
}

View File

@@ -0,0 +1,90 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.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"
"fmt"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/gid"
)
// ProboMembershipsDriver is a built-in identity source that queries
// iam_memberships + identities for the organization. No external
// connector is needed.
type ProboMembershipsDriver struct {
pg *pg.Client
scope coredata.Scoper
organizationID gid.GID
}
func NewProboMembershipsDriver(
pgClient *pg.Client,
scope coredata.Scoper,
organizationID gid.GID,
) *ProboMembershipsDriver {
return &ProboMembershipsDriver{
pg: pgClient,
scope: scope,
organizationID: organizationID,
}
}
func (d *ProboMembershipsDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error) {
var records []AccountRecord
err := d.pg.WithConn(
ctx,
func(conn pg.Conn) error {
accounts, err := coredata.LoadMembershipAccountsByOrganizationID(
ctx,
conn,
d.scope,
d.organizationID,
)
if err != nil {
return fmt.Errorf("cannot load membership accounts: %w", err)
}
for _, account := range accounts {
role := account.Role
isAdmin := role == string(coredata.MembershipRoleOwner) || role == string(coredata.MembershipRoleAdmin)
createdAt := account.CreatedAt
records = append(records, AccountRecord{
Email: account.Email,
FullName: account.FullName,
Role: role,
Active: account.State == string(coredata.ProfileStateActive),
IsAdmin: isAdmin,
ExternalID: account.ID.String(),
CreatedAt: &createdAt,
MFAStatus: coredata.MFAStatusUnknown,
AuthMethod: coredata.AccessEntryAuthMethodUnknown,
AccountType: coredata.AccessEntryAccountTypeUser,
})
}
return nil
},
)
if err != nil {
return nil, fmt.Errorf("cannot list probo membership accounts: %w", err)
}
return records, nil
}

View File

@@ -0,0 +1,114 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.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"
"time"
"go.probo.inc/probo/pkg/coredata"
)
type ResendDriver struct {
httpClient *http.Client
}
var _ Driver = (*ResendDriver)(nil)
type resendAPIKeysResponse struct {
Data []struct {
ID string `json:"id"`
Name string `json:"name"`
CreatedAt string `json:"created_at"`
LastUsedAt *string `json:"last_used_at"`
} `json:"data"`
}
const resendAPIKeysEndpoint = "https://api.resend.com/api-keys"
func NewResendDriver(httpClient *http.Client) *ResendDriver {
return &ResendDriver{
httpClient: httpClient,
}
}
func (d *ResendDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error) {
resp, err := d.fetchAPIKeys(ctx)
if err != nil {
return nil, err
}
var records []AccountRecord
for _, k := range resp.Data {
record := AccountRecord{
FullName: k.Name,
Active: true,
IsAdmin: false,
ExternalID: k.ID,
MFAStatus: coredata.MFAStatusUnknown,
AuthMethod: coredata.AccessEntryAuthMethodUnknown,
AccountType: coredata.AccessEntryAccountTypeServiceAccount,
}
if k.CreatedAt != "" {
if t, err := time.Parse(time.RFC3339, k.CreatedAt); err == nil {
record.CreatedAt = &t
}
}
if k.LastUsedAt != nil {
if t, err := time.Parse(time.RFC3339, *k.LastUsedAt); err == nil {
record.LastLogin = &t
}
}
if record.FullName != "" || record.Email != "" {
records = append(records, record)
}
}
return records, nil
}
func (d *ResendDriver) fetchAPIKeys(ctx context.Context) (*resendAPIKeysResponse, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, resendAPIKeysEndpoint, nil)
if err != nil {
return nil, fmt.Errorf("cannot create resend api-keys request: %w", err)
}
req.Header.Set("Accept", "application/json")
httpResp, err := d.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("cannot execute resend api-keys request: %w", err)
}
defer func() {
_ = httpResp.Body.Close()
}()
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
return nil, fmt.Errorf("cannot fetch resend api-keys: unexpected status %d", httpResp.StatusCode)
}
var resp resendAPIKeysResponse
if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil {
return nil, fmt.Errorf("cannot decode resend api-keys response: %w", err)
}
return &resp, nil
}

View File

@@ -0,0 +1,42 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.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"
"go.probo.inc/probo/pkg/coredata"
)
func TestResendDriver(t *testing.T) {
t.Parallel()
rec := newRecorder(t, "testdata/resend", "RESEND_TOKEN")
client := newVCRClient(rec, bearerAuth(os.Getenv("RESEND_TOKEN")))
driver := NewResendDriver(client)
records, err := driver.ListAccounts(context.Background())
require.NoError(t, err)
require.NotEmpty(t, records)
r := records[0]
assert.NotEmpty(t, r.FullName)
assert.NotEmpty(t, r.ExternalID)
assert.Equal(t, coredata.AccessEntryAccountTypeServiceAccount, r.AccountType)
}

View File

@@ -0,0 +1,228 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.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"
"time"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/rfc5988"
)
// SentryDriver fetches organization members from Sentry via Bearer
// token-authenticated REST API requests.
type SentryDriver struct {
httpClient *http.Client
orgSlug string
}
var _ Driver = (*SentryDriver)(nil)
type sentryMember struct {
ID string `json:"id"`
Email string `json:"email"`
Name string `json:"name"`
Pending bool `json:"pending"`
OrgRole string `json:"orgRole"`
DateCreated string `json:"dateCreated"`
Flags map[string]bool `json:"flags"`
User *sentryUser `json:"user"`
}
type sentryUser struct {
ID string `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
IsActive bool `json:"isActive"`
Has2FA bool `json:"has2fa"`
LastLogin string `json:"lastLogin"`
HasPasswordAuth bool `json:"hasPasswordAuth"`
}
func NewSentryDriver(httpClient *http.Client, orgSlug string) *SentryDriver {
return &SentryDriver{
httpClient: httpClient,
orgSlug: orgSlug,
}
}
func (d *SentryDriver) resolveOrgSlug(ctx context.Context) (string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://sentry.io/api/0/organizations/?member=true", nil)
if err != nil {
return "", fmt.Errorf("cannot create sentry organizations request: %w", err)
}
req.Header.Set("Accept", "application/json")
resp, err := d.httpClient.Do(req)
if err != nil {
return "", fmt.Errorf("cannot fetch sentry organizations: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("cannot fetch sentry organizations: status %d", resp.StatusCode)
}
var orgs []struct {
Slug string `json:"slug"`
}
if err := json.NewDecoder(resp.Body).Decode(&orgs); err != nil {
return "", fmt.Errorf("cannot decode sentry organizations response: %w", err)
}
if len(orgs) == 0 {
return "", fmt.Errorf("no sentry organizations found for this token")
}
return orgs[0].Slug, nil
}
func (d *SentryDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error) {
orgSlug := d.orgSlug
if orgSlug == "" {
slug, err := d.resolveOrgSlug(ctx)
if err != nil {
return nil, fmt.Errorf("cannot resolve sentry organization slug: %w", err)
}
orgSlug = slug
}
var records []AccountRecord
nextURL := fmt.Sprintf(
"https://sentry.io/api/0/organizations/%s/members/",
orgSlug,
)
for range maxPaginationPages {
members, linkHeader, err := d.queryMembers(ctx, nextURL)
if err != nil {
return nil, err
}
for _, m := range members {
fullName := m.Name
if fullName == "" && m.User != nil {
fullName = m.User.Name
}
active := !m.Pending
if m.User != nil {
active = active && m.User.IsActive
}
isAdmin := m.OrgRole == "admin" || m.OrgRole == "owner"
mfaStatus := coredata.MFAStatusUnknown
if m.User != nil {
if m.User.Has2FA {
mfaStatus = coredata.MFAStatusEnabled
} else {
mfaStatus = coredata.MFAStatusDisabled
}
}
authMethod := sentryAuthMethod(m.Flags, m.User)
record := AccountRecord{
Email: m.Email,
FullName: fullName,
Role: m.OrgRole,
Active: active,
IsAdmin: isAdmin,
ExternalID: m.ID,
MFAStatus: mfaStatus,
AuthMethod: authMethod,
AccountType: coredata.AccessEntryAccountTypeUser,
}
if m.User != nil && m.User.LastLogin != "" {
if t, err := time.Parse(time.RFC3339, m.User.LastLogin); err == nil {
record.LastLogin = &t
}
}
if m.DateCreated != "" {
if t, err := time.Parse(time.RFC3339, m.DateCreated); err == nil {
record.CreatedAt = &t
}
}
if record.Email != "" {
records = append(records, record)
}
}
nextURL = sentryNextLink(linkHeader)
if nextURL == "" {
return records, nil
}
}
return nil, fmt.Errorf("cannot list all sentry accounts: %w", ErrPaginationLimitReached)
}
func (d *SentryDriver) queryMembers(ctx context.Context, url string) ([]sentryMember, string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, "", fmt.Errorf("cannot create sentry members request: %w", err)
}
httpResp, err := d.httpClient.Do(req)
if err != nil {
return nil, "", fmt.Errorf("cannot execute sentry members request: %w", err)
}
defer func() {
_ = httpResp.Body.Close()
}()
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
return nil, "", fmt.Errorf("cannot fetch sentry members: unexpected status %d", httpResp.StatusCode)
}
var members []sentryMember
if err := json.NewDecoder(httpResp.Body).Decode(&members); err != nil {
return nil, "", fmt.Errorf("cannot decode sentry members response: %w", err)
}
return members, httpResp.Header.Get("Link"), nil
}
// sentryNextLink extracts the next page URL from a Sentry Link header.
// It returns the URL for the entry with rel="next" and results="true", or
// an empty string if no such entry exists.
func sentryNextLink(header string) string {
for _, link := range rfc5988.Parse(header) {
if link.Params["rel"] == "next" && link.Params["results"] == "true" {
return link.URL
}
}
return ""
}
func sentryAuthMethod(flags map[string]bool, user *sentryUser) coredata.AccessEntryAuthMethod {
if flags["sso:linked"] {
return coredata.AccessEntryAuthMethodSSO
}
if user != nil && user.HasPasswordAuth {
return coredata.AccessEntryAuthMethodPassword
}
return coredata.AccessEntryAuthMethodUnknown
}

View File

@@ -0,0 +1,47 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.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 TestSentryDriver(t *testing.T) {
t.Parallel()
rec := newRecorder(t, "testdata/sentry", "SENTRY_TOKEN")
client := newVCRClient(rec, bearerAuth(os.Getenv("SENTRY_TOKEN")))
orgSlug := os.Getenv("SENTRY_ORG_SLUG")
if orgSlug == "" {
orgSlug = "acme-corp"
}
driver := NewSentryDriver(client, orgSlug)
records, err := driver.ListAccounts(context.Background())
require.NoError(t, err)
require.NotEmpty(t, records)
r := records[0]
assert.NotEmpty(t, r.Email)
assert.NotEmpty(t, r.FullName)
assert.NotEmpty(t, r.ExternalID)
assert.NotEmpty(t, r.Role)
}

View File

@@ -0,0 +1,184 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.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"
"go.probo.inc/probo/pkg/coredata"
)
type SlackDriver struct {
httpClient *http.Client
}
var _ Driver = (*SlackDriver)(nil)
type slackUsersListResponse struct {
OK bool `json:"ok"`
Error string `json:"error,omitempty"`
Members []slackMember `json:"members"`
ResponseMetadata slackResponseMetadata `json:"response_metadata"`
}
type slackResponseMetadata struct {
NextCursor string `json:"next_cursor"`
}
type slackMember struct {
ID string `json:"id"`
Name string `json:"name"`
RealName string `json:"real_name"`
Deleted bool `json:"deleted"`
IsAdmin bool `json:"is_admin"`
IsOwner bool `json:"is_owner"`
IsPrimaryOwner bool `json:"is_primary_owner"`
IsRestricted bool `json:"is_restricted"`
IsUltraRestricted bool `json:"is_ultra_restricted"`
IsBot bool `json:"is_bot"`
IsAppUser bool `json:"is_app_user"`
Has2FA bool `json:"has_2fa"`
Updated int `json:"updated"`
Profile slackProfile `json:"profile"`
}
type slackProfile struct {
Email string `json:"email"`
Title string `json:"title"`
}
const slackUsersListEndpoint = "https://slack.com/api/users.list"
func NewSlackDriver(httpClient *http.Client) *SlackDriver {
return &SlackDriver{
httpClient: httpClient,
}
}
func (d *SlackDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error) {
var (
records []AccountRecord
cursor string
)
for range maxPaginationPages {
resp, err := d.queryUsers(ctx, cursor)
if err != nil {
return nil, err
}
if !resp.OK {
return nil, fmt.Errorf("slack users.list request failed: %s", resp.Error)
}
for _, m := range resp.Members {
if m.ID == "USLACKBOT" {
continue
}
accountType := coredata.AccessEntryAccountTypeUser
if m.IsBot || m.IsAppUser {
accountType = coredata.AccessEntryAccountTypeServiceAccount
}
record := AccountRecord{
Email: m.Profile.Email,
FullName: m.RealName,
JobTitle: m.Profile.Title,
Role: slackRole(m),
Active: !m.Deleted,
IsAdmin: m.IsAdmin || m.IsOwner || m.IsPrimaryOwner,
ExternalID: m.ID,
MFAStatus: slackMFAStatus(m.Has2FA),
AuthMethod: coredata.AccessEntryAuthMethodUnknown,
AccountType: accountType,
}
// Note: Slack's Updated field is the profile update time, not
// the last login time, so we intentionally do not map it.
if record.Email != "" {
records = append(records, record)
}
}
if resp.ResponseMetadata.NextCursor == "" {
return records, nil
}
cursor = resp.ResponseMetadata.NextCursor
}
return nil, fmt.Errorf("cannot list all slack accounts: %w", ErrPaginationLimitReached)
}
func (d *SlackDriver) queryUsers(ctx context.Context, cursor string) (*slackUsersListResponse, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, slackUsersListEndpoint, nil)
if err != nil {
return nil, fmt.Errorf("cannot create slack users.list request: %w", err)
}
q := req.URL.Query()
q.Set("limit", "200")
if cursor != "" {
q.Set("cursor", cursor)
}
req.URL.RawQuery = q.Encode()
httpResp, err := d.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("cannot execute slack users.list request: %w", err)
}
defer func() {
_ = httpResp.Body.Close()
}()
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
return nil, fmt.Errorf("cannot fetch slack users: unexpected status %d", httpResp.StatusCode)
}
var resp slackUsersListResponse
if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil {
return nil, fmt.Errorf("cannot decode slack users.list response: %w", err)
}
return &resp, nil
}
func slackRole(m slackMember) string {
switch {
case m.IsPrimaryOwner:
return "Primary Owner"
case m.IsOwner:
return "Owner"
case m.IsAdmin:
return "Admin"
case m.IsUltraRestricted:
return "Ultra Restricted"
case m.IsRestricted:
return "Restricted"
default:
return "Member"
}
}
func slackMFAStatus(has2FA bool) coredata.MFAStatus {
if has2FA {
return coredata.MFAStatusEnabled
}
return coredata.MFAStatusDisabled
}

View File

@@ -0,0 +1,48 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.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 TestSlackDriver(t *testing.T) {
t.Parallel()
rec := newRecorder(t, "testdata/slack", "SLACK_TOKEN")
client := newVCRClient(rec, bearerAuth(os.Getenv("SLACK_TOKEN")))
driver := NewSlackDriver(client)
records, err := driver.ListAccounts(context.Background())
require.NoError(t, err)
require.NotEmpty(t, records)
// Find the first human user (bots may not have email).
var r AccountRecord
for _, rec := range records {
if rec.Email != "" {
r = rec
break
}
}
require.NotEmpty(t, r.Email, "expected at least one record with an email")
assert.NotEmpty(t, r.ExternalID)
assert.NotEmpty(t, r.Role)
}

View File

@@ -0,0 +1,117 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.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"
"go.probo.inc/probo/pkg/coredata"
)
type SupabaseDriver struct {
httpClient *http.Client
orgSlug string
}
var _ Driver = (*SupabaseDriver)(nil)
type supabaseMember struct {
UserID string `json:"user_id"`
Email string `json:"email"`
UserName string `json:"user_name"`
RoleName string `json:"role_name"`
MFAEnabled bool `json:"mfa_enabled"`
}
func NewSupabaseDriver(httpClient *http.Client, orgSlug string) *SupabaseDriver {
return &SupabaseDriver{
httpClient: httpClient,
orgSlug: orgSlug,
}
}
func (d *SupabaseDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error) {
members, err := d.queryMembers(ctx)
if err != nil {
return nil, err
}
var records []AccountRecord
for _, m := range members {
mfaStatus := coredata.MFAStatusDisabled
if m.MFAEnabled {
mfaStatus = coredata.MFAStatusEnabled
}
isAdmin := m.RoleName == "Owner" || m.RoleName == "Administrator"
record := AccountRecord{
Email: m.Email,
FullName: m.UserName,
Role: m.RoleName,
Active: true,
IsAdmin: isAdmin,
ExternalID: m.UserID,
MFAStatus: mfaStatus,
AuthMethod: coredata.AccessEntryAuthMethodUnknown,
AccountType: coredata.AccessEntryAccountTypeUser,
}
records = append(records, record)
}
return records, nil
}
func (d *SupabaseDriver) queryMembers(ctx context.Context) ([]supabaseMember, error) {
u := &url.URL{
Scheme: "https",
Host: "api.supabase.com",
}
u = u.JoinPath("v1", "organizations", d.orgSlug, "members")
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
if err != nil {
return nil, fmt.Errorf("cannot create supabase members request: %w", err)
}
req.Header.Set("Accept", "application/json")
httpResp, err := d.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("cannot execute supabase members request: %w", err)
}
defer func() {
_ = httpResp.Body.Close()
}()
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
return nil, fmt.Errorf(
"cannot fetch supabase members: unexpected status %d",
httpResp.StatusCode,
)
}
var members []supabaseMember
if err := json.NewDecoder(httpResp.Body).Decode(&members); err != nil {
return nil, fmt.Errorf("cannot decode supabase members response: %w", err)
}
return members, nil
}

View File

@@ -0,0 +1,46 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.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 TestSupabaseDriver(t *testing.T) {
t.Parallel()
rec := newRecorder(t, "testdata/supabase", "SUPABASE_TOKEN")
client := newVCRClient(rec, bearerAuth(os.Getenv("SUPABASE_TOKEN")))
orgSlug := os.Getenv("SUPABASE_ORG_SLUG")
if orgSlug == "" {
orgSlug = "acme-corp"
}
driver := NewSupabaseDriver(client, orgSlug)
records, err := driver.ListAccounts(context.Background())
require.NoError(t, err)
require.NotEmpty(t, records)
r := records[0]
assert.NotEmpty(t, r.FullName)
assert.NotEmpty(t, r.ExternalID)
assert.NotEmpty(t, r.Role)
}

View File

@@ -0,0 +1,186 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.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"
"time"
"go.probo.inc/probo/pkg/coredata"
)
type TallyDriver struct {
httpClient *http.Client
organizationID string
}
var _ Driver = (*TallyDriver)(nil)
type tallyUser struct {
ID string `json:"id"`
FirstName string `json:"firstName"`
LastName string `json:"lastName"`
FullName string `json:"fullName"`
Email string `json:"email"`
IsDeleted bool `json:"isDeleted"`
HasTwoFactorEnabled bool `json:"hasTwoFactorEnabled"`
CreatedAt time.Time `json:"createdAt"`
}
type tallyInvite struct {
ID string `json:"id"`
Email string `json:"email"`
}
func NewTallyDriver(httpClient *http.Client, organizationID string) *TallyDriver {
return &TallyDriver{
httpClient: httpClient,
organizationID: organizationID,
}
}
func (d *TallyDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error) {
records, err := d.listUsers(ctx)
if err != nil {
return nil, err
}
inviteRecords, err := d.listInvites(ctx)
if err != nil {
return nil, err
}
records = append(records, inviteRecords...)
return records, nil
}
func (d *TallyDriver) listUsers(ctx context.Context) ([]AccountRecord, error) {
u := &url.URL{
Scheme: "https",
Host: "api.tally.so",
}
u = u.JoinPath("organizations", d.organizationID, "users")
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
if err != nil {
return nil, fmt.Errorf("cannot create tally 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 tally users request: %w", err)
}
defer func() {
_ = httpResp.Body.Close()
}()
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
return nil, fmt.Errorf(
"cannot fetch tally users: unexpected status %d",
httpResp.StatusCode,
)
}
var users []tallyUser
if err := json.NewDecoder(httpResp.Body).Decode(&users); err != nil {
return nil, fmt.Errorf("cannot decode tally users response: %w", err)
}
var records []AccountRecord
for _, u := range users {
mfaStatus := coredata.MFAStatusDisabled
if u.HasTwoFactorEnabled {
mfaStatus = coredata.MFAStatusEnabled
}
record := AccountRecord{
Email: u.Email,
FullName: u.FullName,
Active: !u.IsDeleted,
ExternalID: u.ID,
MFAStatus: mfaStatus,
AuthMethod: coredata.AccessEntryAuthMethodUnknown,
AccountType: coredata.AccessEntryAccountTypeUser,
CreatedAt: new(u.CreatedAt),
}
if record.Email != "" {
records = append(records, record)
}
}
return records, nil
}
func (d *TallyDriver) listInvites(ctx context.Context) ([]AccountRecord, error) {
u := &url.URL{
Scheme: "https",
Host: "api.tally.so",
}
u = u.JoinPath("organizations", d.organizationID, "invites")
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
if err != nil {
return nil, fmt.Errorf("cannot create tally invites request: %w", err)
}
req.Header.Set("Accept", "application/json")
httpResp, err := d.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("cannot execute tally invites request: %w", err)
}
defer func() {
_ = httpResp.Body.Close()
}()
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
return nil, fmt.Errorf(
"cannot fetch tally invites: unexpected status %d",
httpResp.StatusCode,
)
}
var invites []tallyInvite
if err := json.NewDecoder(httpResp.Body).Decode(&invites); err != nil {
return nil, fmt.Errorf("cannot decode tally invites response: %w", err)
}
var records []AccountRecord
for _, inv := range invites {
record := AccountRecord{
Email: inv.Email,
Active: false,
ExternalID: inv.ID,
MFAStatus: coredata.MFAStatusUnknown,
AuthMethod: coredata.AccessEntryAuthMethodUnknown,
AccountType: coredata.AccessEntryAccountTypeUser,
Role: "Invited",
}
if record.Email != "" {
records = append(records, record)
}
}
return records, nil
}

View File

@@ -0,0 +1,46 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.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 TestTallyDriver(t *testing.T) {
t.Parallel()
rec := newRecorder(t, "testdata/tally", "TALLY_TOKEN")
client := newVCRClient(rec, bearerAuth(os.Getenv("TALLY_TOKEN")))
orgID := os.Getenv("TALLY_ORG_ID")
if orgID == "" {
orgID = "wvBzxD"
}
driver := NewTallyDriver(client, orgID)
records, err := driver.ListAccounts(context.Background())
require.NoError(t, err)
require.NotEmpty(t, records)
r := records[0]
assert.NotEmpty(t, r.Email)
assert.NotEmpty(t, r.FullName)
assert.NotEmpty(t, r.ExternalID)
}

View File

@@ -0,0 +1,42 @@
---
version: 2
interactions:
- id: 0
request:
proto: HTTP/1.1
proto_major: 1
proto_minor: 1
content_length: 0
host: platform.brexapis.com
headers:
Accept:
- application/json
Content-Type:
- application/json
url: https://platform.brexapis.com/v2/users
method: GET
response:
proto: HTTP/2.0
proto_major: 2
proto_minor: 0
content_length: -1
uncompressed: true
body: '{"items":[{"id":"cuuser_000000000000000000000001","first_name":"Alice","last_name":"Martin","email":"alice@example.com","status":"ACTIVE","manager_id":"cuuser_000000000000000000000006","user_role":"EMPLOYEE"},{"id":"cuuser_000000000000000000000002","first_name":"Bob","last_name":"Wilson","email":"bob@example.com","status":"ACTIVE","manager_id":"cuuser_000000000000000000000005","user_role":"EMPLOYEE"},{"id":"cuuser_000000000000000000000003","first_name":"Charlie","last_name":"Brown","email":"charlie@example.com","status":"ACTIVE","manager_id":"cuuser_000000000000000000000006","user_role":"EMPLOYEE"},{"id":"cuuser_000000000000000000000004","first_name":"Dana","last_name":"Contractor","email":"dana@contractor.example.com","status":"ACTIVE","user_role":"BOOKKEEPER"},{"id":"cuuser_000000000000000000000005","first_name":"John","last_name":"Smith","email":"john@example.com","status":"ACTIVE","user_role":"ACCOUNT_ADMIN"},{"id":"cuuser_000000000000000000000006","first_name":"Jane","last_name":"Doe","email":"jane@example.com","status":"ACTIVE","user_role":"ACCOUNT_ADMIN"}]}'
headers:
Content-Type:
- application/json
Date:
- Thu, 26 Mar 2026 12:54:04 GMT
Server:
- istio-envoy
X-Brex-Parent-Id:
- "2660134401200686227"
X-Brex-Sampling-Priority:
- "1"
X-Brex-Trace-Id:
- "17144222899071086561"
X-Envoy-Upstream-Service-Time:
- "590"
status: 200 OK
code: 200
duration: 1.095966125s

View File

@@ -0,0 +1,130 @@
---
version: 2
interactions:
- id: 0
request:
proto: HTTP/1.1
proto_major: 1
proto_minor: 1
content_length: 0
host: api.cloudflare.com
form:
page:
- "1"
per_page:
- "50"
headers:
Accept:
- application/json
Content-Type:
- application/json
url: https://api.cloudflare.com/client/v4/accounts?page=1&per_page=50
method: GET
response:
proto: HTTP/2.0
proto_major: 2
proto_minor: 0
content_length: -1
uncompressed: true
body: '{"result":[{"id":"a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0","name":"Acme Corp Account","type":"standard","settings":{"enforce_twofactor":false,"api_access_enabled":null,"access_approval_expiry":null,"abuse_contact_email":null},"legacy_flags":{"enterprise_zone_quota":{"maximum":0,"current":0,"available":0}},"created_on":"2024-08-01T13:33:08.703547Z"}],"result_info":{"page":1,"per_page":50,"total_pages":1,"count":1,"total_count":1},"success":true,"errors":[],"messages":[]}'
headers:
Api-Version:
- "2026-03-26"
Cache-Control:
- no-store, no-cache, must-revalidate, post-check=0, pre-check=0
Cf-Auditlog-Id:
- 019d2a35-91c4-7736-896b-b006409d7e6f
Cf-Cache-Status:
- DYNAMIC
Cf-Ray:
- 9e264d55fea00272-CDG
Content-Type:
- application/json
Date:
- Thu, 26 Mar 2026 12:54:08 GMT
Expires:
- Sun, 25 Jan 1981 05:00:00 GMT
Pragma:
- no-cache
Ratelimit:
- '"default";r=1199;t=1'
Ratelimit-Policy:
- '"default";q=1200;w=300'
Server:
- cloudflare
Set-Cookie:
- __cflb=04dTob1Z8hkaUxg6DoHNs8KRCyeFsheZBgh4Z4g8w7; SameSite=Lax; path=/; expires=Thu, 26-Mar-26 15:24:09 GMT; HttpOnly
- __cf_bm=LyuTKDqNQ5tvh.PHF8_9EdNmXMvJqY4R5hcWPgVvMzU-1774529647.0347695-1.0.1.1-UnR7cgawFlfSt.v3DMn.2_k9YyICl.HC97ZYLn4wz_18Abz7sbBAHiVmjrFTui2_2Yv6xMPC5aQHLne9dkWG9AEfHjgOciGnCcIxV_5R3cU9fx1I.X6S2ULi.g02JOhT; HttpOnly; Secure; Path=/; Domain=api.cloudflare.com; Expires=Thu, 26 Mar 2026 13:24:08 GMT
- _cfuvid=RPBCBz3UI_HliU0tQFxDPvA0iEo4L.Sl9rAKMd.y6gk-1774529647.0347695-1.0.1.1-pURMyXvOZFChPV9dMfEZBk7C500xxDGyZbysJ0p1SlM; HttpOnly; SameSite=None; Secure; Path=/; Domain=api.cloudflare.com
Strict-Transport-Security:
- max-age=31536000
Vary:
- Accept-Encoding
X-Content-Type-Options:
- nosniff
X-Frame-Options:
- SAMEORIGIN
status: 200 OK
code: 200
duration: 1.137052667s
- id: 1
request:
proto: HTTP/1.1
proto_major: 1
proto_minor: 1
content_length: 0
host: api.cloudflare.com
form:
page:
- "1"
per_page:
- "50"
headers:
Accept:
- application/json
Content-Type:
- application/json
url: https://api.cloudflare.com/client/v4/accounts/a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0/members?page=1&per_page=50
method: GET
response:
proto: HTTP/2.0
proto_major: 2
proto_minor: 0
content_length: -1
uncompressed: true
body: |
{"result":[{"id":"b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0","email":"john@example.com","user":{"id":"c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0","first_name":null,"last_name":null,"email":"john@example.com","two_factor_authentication_enabled":true},"status":"accepted","api_access_enabled":null,"policies":[{"id":"94f6cdbcf2914b68b69a1c6ccb407ff2","access":"allow","permission_groups":[{"id":"8e23b19e4e0d44c29d239c5688ba8cbb","name":"Super Administrator - All Privileges","meta":{"category":"general","description":"Can edit any Cloudflare setting, make purchases, update billing, and manage memberships. Super Administrators can revoke the access of other Super Administrators.","editable":"false","label":"all_privileges","scopes":"com.cloudflare.api.account"}}],"resource_groups":[{"id":"4a190dd8042e46bfb5c86663050c37bb","name":"com.cloudflare.api.account.a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0","meta":{"editable":"false"},"scope":{"key":"com.cloudflare.api.account.a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0","objects":[{"key":"*"}]}}]}],"roles":[{"id":"33666b9c79b9a5273fc7344ff42f953d","name":"Super Administrator - All Privileges","description":"Can edit any Cloudflare setting, make purchases, update billing, and manage memberships. Super Administrators can revoke the access of other Super Administrators.","permissions":{"access":{"edit":true,"read":true},"analytics":{"edit":false,"read":true},"api_gateway":{"edit":true,"read":true},"app":{"edit":true,"read":false},"auditlogs":{"edit":false,"read":true},"billing":{"edit":true,"read":true},"blocks":{"edit":true,"read":true},"cache_purge":{"edit":true,"read":false},"casb":{"edit":true,"read":true},"cds":{"edit":true,"read":true},"cds_compute_account":{"edit":true,"read":true},"ces_analytics":{"edit":false,"read":true},"ces_integration":{"edit":true,"read":true},"ces_phishguard":{"edit":false,"read":true},"ces_policies":{"edit":true,"read":true},"ces_pra_report":{"edit":true,"read":true},"ces_search":{"action":true,"edit":false,"preview":true,"raw":true,"read":true,"trace":true},"ces_settings":{"edit":true,"read":true},"ces_submissions":{"edit":true,"read":true},"cf1_integration":{"casb":true,"ces":true,"edit":true,"read":true},"d1":{"edit":true,"read":false},"dash_sso":{"edit":true,"read":true},"dex":{"edit":true,"read":true},"dns_records":{"edit":true,"read":true},"domain":{"edit":false,"read":true},"fbm":{"edit":true,"read":true},"fbm_acc":{"edit":true,"read":false},"healthchecks":{"edit":true,"read":true},"http_applications":{"edit":true,"read":true},"image":{"edit":true,"read":true},"integration":{"edit":true,"install":true,"read":true},"lb":{"edit":true,"read":true},"legal":{"edit":true,"read":true},"logs":{"edit":true,"read":true},"magic":{"edit":true,"read":true},"member":{"edit":true,"read":true},"organization":{"edit":true,"read":true},"page_shield":{"edit":true,"read":true},"query_cache":{"edit":true,"read":true},"r2_bucket":{"edit":true,"read":true},"r2_bucket_item":{"edit":true,"read":true},"r2_bucket_warehouse":{"edit":true,"read":true},"r2_bucket_warehouse_sql":{"edit":false,"read":true},"resilience":{"edit":true,"read":true},"ssl":{"edit":true,"read":true},"stream":{"edit":true,"read":true},"subscription":{"edit":true,"read":true},"teams":{"edit":true,"pii":true,"read":true,"report":true},"teams_device":{"edit":false,"read":true},"vectorize":{"edit":true,"read":true},"waf":{"edit":true,"read":true},"waitingroom":{"edit":true,"read":true},"web3":{"edit":true,"read":true},"worker":{"edit":true,"read":true},"zaraz":{"edit":true,"publish":true,"read":true},"zone":{"edit":true,"read":true},"zone_settings":{"edit":true,"read":true},"zone_versioning":{"edit":true,"read":true}}}]}],"result_info":{"page":1,"per_page":50,"total_pages":1,"count":1,"total_count":1},"success":true,"errors":[],"messages":[]}
headers:
Allow:
- GET, POST
Api-Version:
- "2026-03-26"
Cache-Control:
- private,no-cache,no-store
Cf-Auditlog-Id:
- 019d2a35-9614-75cb-bdee-bd4b7032bff2
Cf-Cache-Status:
- DYNAMIC
Cf-Ray:
- 9e264d5cdd890272-CDG
Content-Type:
- application/json
Date:
- Thu, 26 Mar 2026 12:54:08 GMT
Pragma:
- no-cache
Ratelimit:
- '"default";r=1199;t=1'
Ratelimit-Policy:
- '"default";q=1200;w=300'
Server:
- cloudflare
Set-Cookie:
- __cflb=04dTob1Z8hkaUxg6DoHNs8KRCyeFsheZMPN5MtAkCd; SameSite=Lax; path=/; expires=Thu, 26-Mar-26 15:24:09 GMT; HttpOnly
- __cf_bm=wb_wrUP01dJHoUzazZm1.uiyEnIDd9T4FfBjy0TCTA8-1774529648.1355844-1.0.1.1-U1ANV4vetzyj.IHgptRMPsZRo425I1beWBhJj9Sk6fTCNbehiJ5wQyZfEXPnzaLii5IkN8TZ0e.I7RGjjZof01_fwKAA46tNZ17xG032QXlmKnnmU4DrxL8cvpogHIFL; HttpOnly; Secure; Path=/; Domain=api.cloudflare.com; Expires=Thu, 26 Mar 2026 13:24:08 GMT
- _cfuvid=sUYD5zj5lijQFwgMQsqTkqNzvzAH20wbHjXkOak6_V0-1774529648.1355844-1.0.1.1-b3hM8gyNj.5d_wxoQiozNeDeAi_pUUdL6KSJTAHjBSU; HttpOnly; SameSite=None; Secure; Path=/; Domain=api.cloudflare.com
Vary:
- Accept-Encoding
status: 200 OK
code: 200
duration: 739.361791ms

View File

@@ -0,0 +1,296 @@
---
version: 2
interactions:
- id: 0
request:
proto: HTTP/1.1
proto_major: 1
proto_minor: 1
content_length: 0
host: api.github.com
form:
per_page:
- "100"
headers:
Accept:
- application/vnd.github+json
url: https://api.github.com/orgs/acme-corp/members?per_page=100
method: GET
response:
proto: HTTP/2.0
proto_major: 2
proto_minor: 0
content_length: -1
uncompressed: true
body: '[{"login":"jdoe","id":100001,"node_id":"XYZQ6VXNlcjEwMDAw","avatar_url":"","gravatar_id":"","url":"https://api.github.com/users/jdoe","html_url":"https://github.com/jdoe","followers_url":"https://api.github.com/users/jdoe/followers","following_url":"https://api.github.com/users/jdoe/following{/other_user}","gists_url":"https://api.github.com/users/jdoe/gists{/gist_id}","starred_url":"https://api.github.com/users/jdoe/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/jdoe/subscriptions","organizations_url":"https://api.github.com/users/jdoe/orgs","repos_url":"https://api.github.com/users/jdoe/repos","events_url":"https://api.github.com/users/jdoe/events{/privacy}","received_events_url":"https://api.github.com/users/jdoe/received_events","type":"User","user_view_type":"public","site_admin":false}]'
headers:
Access-Control-Allow-Origin:
- '*'
Access-Control-Expose-Headers:
- ETag, Link, Location, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Used, X-RateLimit-Resource, X-RateLimit-Reset, X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Poll-Interval, X-GitHub-Media-Type, X-GitHub-SSO, X-GitHub-Request-Id, Deprecation, Sunset
Cache-Control:
- private, max-age=60, s-maxage=60
Content-Security-Policy:
- default-src 'none'
Content-Type:
- application/json; charset=utf-8
Date:
- Thu, 26 Mar 2026 13:20:24 GMT
Etag:
- W/"ff2048ab2918047a22ee474a92af74dff9570f18dc6b59556889b02a90e5291f"
Github-Authentication-Token-Expiration:
- 2026-04-25 13:21:33 +0200
Referrer-Policy:
- origin-when-cross-origin, strict-origin-when-cross-origin
Server:
- github.com
Strict-Transport-Security:
- max-age=31536000; includeSubdomains; preload
Vary:
- Accept, Authorization, Cookie, X-GitHub-OTP,Accept-Encoding, Accept, X-Requested-With
X-Accepted-Github-Permissions:
- members=read
X-Content-Type-Options:
- nosniff
X-Frame-Options:
- deny
X-Github-Api-Version-Selected:
- "2022-11-28"
X-Github-Media-Type:
- github.v3; format=json
X-Github-Request-Id:
- E712:31AC1B:623D7E5:56C7B3B:69C53298
X-Ratelimit-Limit:
- "5000"
X-Ratelimit-Remaining:
- "4999"
X-Ratelimit-Reset:
- "1774534824"
X-Ratelimit-Resource:
- core
X-Ratelimit-Used:
- "1"
X-Xss-Protection:
- "0"
status: 200 OK
code: 200
duration: 282.34525ms
- id: 1
request:
proto: HTTP/1.1
proto_major: 1
proto_minor: 1
content_length: 0
host: api.github.com
form:
filter:
- 2fa_disabled
per_page:
- "100"
headers:
Accept:
- application/vnd.github+json
url: https://api.github.com/orgs/acme-corp/members?filter=2fa_disabled&per_page=100
method: GET
response:
proto: HTTP/2.0
proto_major: 2
proto_minor: 0
content_length: 2
body: '[]'
headers:
Access-Control-Allow-Origin:
- '*'
Access-Control-Expose-Headers:
- ETag, Link, Location, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Used, X-RateLimit-Resource, X-RateLimit-Reset, X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Poll-Interval, X-GitHub-Media-Type, X-GitHub-SSO, X-GitHub-Request-Id, Deprecation, Sunset
Cache-Control:
- private, max-age=60, s-maxage=60
Content-Length:
- "2"
Content-Security-Policy:
- default-src 'none'
Content-Type:
- application/json; charset=utf-8
Date:
- Thu, 26 Mar 2026 13:20:24 GMT
Etag:
- '"66d4a6c8d79df8b01adad18bc0608ce26f32b16b3bf0d61ec689cc3a8cda2c37"'
Github-Authentication-Token-Expiration:
- 2026-04-25 13:21:33 +0200
Referrer-Policy:
- origin-when-cross-origin, strict-origin-when-cross-origin
Server:
- github.com
Strict-Transport-Security:
- max-age=31536000; includeSubdomains; preload
Vary:
- Accept, Authorization, Cookie, X-GitHub-OTP,Accept-Encoding, Accept, X-Requested-With
X-Accepted-Github-Permissions:
- members=read
X-Content-Type-Options:
- nosniff
X-Frame-Options:
- deny
X-Github-Api-Version-Selected:
- "2022-11-28"
X-Github-Media-Type:
- github.v3; format=json
X-Github-Request-Id:
- E712:31AC1B:623DA14:56C7D1F:69C53298
X-Ratelimit-Limit:
- "5000"
X-Ratelimit-Remaining:
- "4998"
X-Ratelimit-Reset:
- "1774534824"
X-Ratelimit-Resource:
- core
X-Ratelimit-Used:
- "2"
X-Xss-Protection:
- "0"
status: 200 OK
code: 200
duration: 222.556791ms
- id: 2
request:
proto: HTTP/1.1
proto_major: 1
proto_minor: 1
content_length: 0
host: api.github.com
headers:
Accept:
- application/vnd.github+json
url: https://api.github.com/orgs/acme-corp/memberships/jdoe
method: GET
response:
proto: HTTP/2.0
proto_major: 2
proto_minor: 0
content_length: -1
uncompressed: true
body: '{"url":"https://api.github.com/orgs/acme-corp/memberships/jdoe","state":"active","role":"admin","organization_url":"https://api.github.com/orgs/acme-corp","user":{"login":"jdoe","id":100001,"node_id":"XYZQ6VXNlcjEwMDAw","avatar_url":"","gravatar_id":"","url":"https://api.github.com/users/jdoe","html_url":"https://github.com/jdoe","followers_url":"https://api.github.com/users/jdoe/followers","following_url":"https://api.github.com/users/jdoe/following{/other_user}","gists_url":"https://api.github.com/users/jdoe/gists{/gist_id}","starred_url":"https://api.github.com/users/jdoe/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/jdoe/subscriptions","organizations_url":"https://api.github.com/users/jdoe/orgs","repos_url":"https://api.github.com/users/jdoe/repos","events_url":"https://api.github.com/users/jdoe/events{/privacy}","received_events_url":"https://api.github.com/users/jdoe/received_events","type":"User","user_view_type":"public","site_admin":false},"direct_membership":true,"enterprise_teams_providing_indirect_membership":[],"organization":{"login":"acme-corp","id":100002,"node_id":"O_kgDOFake0Rg","url":"https://api.github.com/orgs/acme-corp","repos_url":"https://api.github.com/orgs/acme-corp/repos","events_url":"https://api.github.com/orgs/acme-corp/events","hooks_url":"https://api.github.com/orgs/acme-corp/hooks","issues_url":"https://api.github.com/orgs/acme-corp/issues","members_url":"https://api.github.com/orgs/acme-corp/members{/member}","public_members_url":"https://api.github.com/orgs/acme-corp/public_members{/member}","avatar_url":"","description":""}}'
headers:
Access-Control-Allow-Origin:
- '*'
Access-Control-Expose-Headers:
- ETag, Link, Location, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Used, X-RateLimit-Resource, X-RateLimit-Reset, X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Poll-Interval, X-GitHub-Media-Type, X-GitHub-SSO, X-GitHub-Request-Id, Deprecation, Sunset
Cache-Control:
- private, max-age=60, s-maxage=60
Content-Security-Policy:
- default-src 'none'
Content-Type:
- application/json; charset=utf-8
Date:
- Thu, 26 Mar 2026 13:20:25 GMT
Etag:
- W/"5f52c04e58dd224bb535f57f82fa45937ab13a419b2fafa841ee9392cc8dc444"
Github-Authentication-Token-Expiration:
- 2026-04-25 13:21:33 +0200
Referrer-Policy:
- origin-when-cross-origin, strict-origin-when-cross-origin
Server:
- github.com
Strict-Transport-Security:
- max-age=31536000; includeSubdomains; preload
Vary:
- Accept, Authorization, Cookie, X-GitHub-OTP,Accept-Encoding, Accept, X-Requested-With
X-Accepted-Github-Permissions:
- members=read
X-Content-Type-Options:
- nosniff
X-Frame-Options:
- deny
X-Github-Api-Version-Selected:
- "2022-11-28"
X-Github-Media-Type:
- github.v3; format=json
X-Github-Request-Id:
- E712:31AC1B:623DC2B:56C7EF5:69C53298
X-Ratelimit-Limit:
- "5000"
X-Ratelimit-Remaining:
- "4997"
X-Ratelimit-Reset:
- "1774534824"
X-Ratelimit-Resource:
- core
X-Ratelimit-Used:
- "3"
X-Xss-Protection:
- "0"
status: 200 OK
code: 200
duration: 207.5905ms
- id: 3
request:
proto: HTTP/1.1
proto_major: 1
proto_minor: 1
content_length: 0
host: api.github.com
headers:
Accept:
- application/vnd.github+json
url: https://api.github.com/users/jdoe
method: GET
response:
proto: HTTP/2.0
proto_major: 2
proto_minor: 0
content_length: -1
uncompressed: true
body: '{"login":"jdoe","id":100001,"node_id":"XYZQ6VXNlcjEwMDAw","avatar_url":"","gravatar_id":"","url":"https://api.github.com/users/jdoe","html_url":"https://github.com/jdoe","followers_url":"https://api.github.com/users/jdoe/followers","following_url":"https://api.github.com/users/jdoe/following{/other_user}","gists_url":"https://api.github.com/users/jdoe/gists{/gist_id}","starred_url":"https://api.github.com/users/jdoe/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/jdoe/subscriptions","organizations_url":"https://api.github.com/users/jdoe/orgs","repos_url":"https://api.github.com/users/jdoe/repos","events_url":"https://api.github.com/users/jdoe/events{/privacy}","received_events_url":"https://api.github.com/users/jdoe/received_events","type":"User","user_view_type":"public","site_admin":false,"name":"Jane Doe","company":null,"blog":"","location":"","email":null,"hireable":null,"bio":null,"twitter_username":null,"public_repos":12,"public_gists":3,"followers":10,"following":5,"created_at":"2009-05-06T20:34:11Z","updated_at":"2026-02-24T10:26:18Z"}'
headers:
Access-Control-Allow-Origin:
- '*'
Access-Control-Expose-Headers:
- ETag, Link, Location, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Used, X-RateLimit-Resource, X-RateLimit-Reset, X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Poll-Interval, X-GitHub-Media-Type, X-GitHub-SSO, X-GitHub-Request-Id, Deprecation, Sunset
Cache-Control:
- private, max-age=60, s-maxage=60
Content-Security-Policy:
- default-src 'none'
Content-Type:
- application/json; charset=utf-8
Date:
- Thu, 26 Mar 2026 13:20:25 GMT
Etag:
- W/"c747d94dd20c979144fc5f2bdd4e88145cf97bb5c5386d56c1c779e980671332"
Github-Authentication-Token-Expiration:
- 2026-04-25 13:21:33 +0200
Last-Modified:
- Tue, 24 Feb 2026 10:26:18 GMT
Referrer-Policy:
- origin-when-cross-origin, strict-origin-when-cross-origin
Server:
- github.com
Strict-Transport-Security:
- max-age=31536000; includeSubdomains; preload
Vary:
- Accept, Authorization, Cookie, X-GitHub-OTP,Accept-Encoding, Accept, X-Requested-With
X-Content-Type-Options:
- nosniff
X-Frame-Options:
- deny
X-Github-Api-Version-Selected:
- "2022-11-28"
X-Github-Media-Type:
- github.v3; format=json
X-Github-Request-Id:
- E712:31AC1B:623DE37:56C80BC:69C53299
X-Ratelimit-Limit:
- "5000"
X-Ratelimit-Remaining:
- "4996"
X-Ratelimit-Reset:
- "1774534824"
X-Ratelimit-Resource:
- core
X-Ratelimit-Used:
- "4"
X-Xss-Protection:
- "0"
status: 200 OK
code: 200
duration: 190.346042ms

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,137 @@
---
version: 2
interactions:
- id: 0
request:
proto: HTTP/1.1
proto_major: 1
proto_minor: 1
content_length: 0
host: api.hubapi.com
headers:
Accept:
- application/json
url: https://api.hubapi.com/settings/v3/users/roles
method: GET
response:
proto: HTTP/2.0
proto_major: 2
proto_minor: 0
content_length: 153
body: '{"status":"error","message":"Account doesn''t have access to roles.","correlationId":"019d2a35-7d8f-7ae2-bbf6-6342ceb0b095","category":"VALIDATION_ERROR"}'
headers:
Access-Control-Allow-Credentials:
- "false"
Cf-Cache-Status:
- DYNAMIC
Cf-Ray:
- 9e264d34984eef36-CDG
Content-Length:
- "153"
Content-Type:
- application/json;charset=utf-8
Date:
- Thu, 26 Mar 2026 12:54:01 GMT
Nel:
- '{"success_fraction":0.01,"report_to":"cf-nel","max_age":604800}'
Report-To:
- '{"endpoints":[{"url":"https:\/\/a.nel.cloudflare.com\/report\/v4?s=AQJsKvuHIV2QL3QAbod63rryJIMLkiSMUEWj9GIPyGtL0x2nmDjJTT2jTK5wZhmW%2BbrKrp7%2BGNHq5GAesRD%2F%2Bm%2FQb02pCiKGoMf%2BaJDy9IrNH249nETyQPjc58vdXA%2FJ"}],"group":"cf-nel","max_age":604800}'
Server:
- cloudflare
Server-Timing:
- hcid;desc="019d2a35-7d8f-7ae2-bbf6-6342ceb0b095", cfr;desc="9e264d357357ef36-CDG"
Set-Cookie:
- __cf_bm=4RLJK.6JUyiG8B7tN3fIH7ZsNHaHg7.NT4d45o29LdQ-1774529641-1.0.1.1-ci1VEnqgpmPAVxsm.Wfu6qz01Wk70GNl3tX5MYFIbnvwmVi1heY0S0T_19KVQa_TJUawOFrHHmuOhUkBMaoIFtLB9PpI0zIry.4Lfx6Pmps; path=/; expires=Thu, 26-Mar-26 13:24:01 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None
Strict-Transport-Security:
- max-age=31536000; includeSubDomains; preload
Vary:
- origin, Accept-Encoding
X-Content-Type-Options:
- nosniff
X-Hubspot-Correlation-Id:
- 019d2a35-7d8f-7ae2-bbf6-6342ceb0b095
X-Hubspot-Ratelimit-Daily:
- "250000"
X-Hubspot-Ratelimit-Daily-Remaining:
- "249999"
X-Hubspot-Ratelimit-Interval-Milliseconds:
- "10000"
X-Hubspot-Ratelimit-Max:
- "100"
X-Hubspot-Ratelimit-Remaining:
- "99"
X-Hubspot-Ratelimit-Secondly:
- "10"
X-Hubspot-Ratelimit-Secondly-Remaining:
- "9"
status: 400 Bad Request
code: 400
duration: 260.217958ms
- id: 1
request:
proto: HTTP/1.1
proto_major: 1
proto_minor: 1
content_length: 0
host: api.hubapi.com
form:
limit:
- "100"
headers:
Accept:
- application/json
url: https://api.hubapi.com/settings/v3/users?limit=100
method: GET
response:
proto: HTTP/2.0
proto_major: 2
proto_minor: 0
content_length: -1
uncompressed: true
body: '{"results":[{"id":"10000001","email":"john@example.com","firstName":"John","lastName":"Smith","roleIds":[],"superAdmin":true},{"id":"10000002","email":"jane@example.com","firstName":"Jane","lastName":"Doe","roleIds":[],"superAdmin":true}]}'
headers:
Access-Control-Allow-Credentials:
- "false"
Cf-Cache-Status:
- DYNAMIC
Cf-Ray:
- 9e264d35eb14ef36-CDG
Content-Type:
- application/json;charset=utf-8
Date:
- Thu, 26 Mar 2026 12:54:02 GMT
Nel:
- '{"success_fraction":0.01,"report_to":"cf-nel","max_age":604800}'
Report-To:
- '{"endpoints":[{"url":"https:\/\/a.nel.cloudflare.com\/report\/v4?s=80JrXPVKlK%2FOAlE%2FWZDhkwEsCPYQItel9E9qSgo%2BqSi8R5j7hnJLtXpH%2BBffnffkPQZoF5lX6qLfTBh65dNj8v1usNa%2Fz%2FMN33nOy4vjJBWW2LUwqQdb84tL5Ibm2Ji6"}],"group":"cf-nel","max_age":604800}'
Server:
- cloudflare
Server-Timing:
- hcid;desc="019d2a35-7e38-7d81-a93c-7babb6730cf2", cfr;desc="9e264d36a3eeef36-CDG"
Set-Cookie:
- __cf_bm=S9t6TJ6fbQ40SO.aEIBymxjviee3C3rcHuiTajw_cr4-1774529642-1.0.1.1-tOR6PEVlUL.2ttF4.l9IsAS11oSsHi8IFgeXmDCgipjPPY.ztKXNvEE1p26R25qVs8jfiUZbRmbRcuOEn64SLXUX6uDGgsetl2X2.JoiLmg; path=/; expires=Thu, 26-Mar-26 13:24:02 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None
Strict-Transport-Security:
- max-age=31536000; includeSubDomains; preload
Vary:
- origin, Accept-Encoding
X-Content-Type-Options:
- nosniff
X-Hubspot-Correlation-Id:
- 019d2a35-7e38-7d81-a93c-7babb6730cf2
X-Hubspot-Ratelimit-Daily:
- "250000"
X-Hubspot-Ratelimit-Daily-Remaining:
- "249998"
X-Hubspot-Ratelimit-Interval-Milliseconds:
- "10000"
X-Hubspot-Ratelimit-Max:
- "100"
X-Hubspot-Ratelimit-Remaining:
- "98"
X-Hubspot-Ratelimit-Secondly:
- "10"
X-Hubspot-Ratelimit-Secondly-Remaining:
- "9"
status: 200 OK
code: 200
duration: 212.332792ms

View File

@@ -0,0 +1,65 @@
---
version: 2
interactions:
- id: 0
request:
proto: HTTP/1.1
proto_major: 1
proto_minor: 1
content_length: 0
host: api.intercom.io
headers:
Accept:
- application/json
Intercom-Version:
- "2.11"
url: https://api.intercom.io/admins
method: GET
response:
proto: HTTP/2.0
proto_major: 2
proto_minor: 0
content_length: -1
uncompressed: true
body: '{"type":"admin.list","admins":[{"type":"admin","email":"john@example.com","id":"1000001","name":"John Smith","away_mode_enabled":false,"away_mode_reassign":false,"has_inbox_seat":true,"team_ids":[],"team_priority_level":{}},{"type":"admin","email":"operator+abc12345@intercom.io","id":"1000002","name":"Fin","away_mode_enabled":false,"away_mode_reassign":false,"has_inbox_seat":false,"team_ids":[],"team_priority_level":{}},{"type":"admin","email":"jane@example.com","id":"1000003","name":"Jane Doe","away_mode_enabled":false,"away_mode_reassign":false,"has_inbox_seat":false,"team_ids":[],"team_priority_level":{}},{"type":"admin","email":"alice@example.com","id":"1000004","name":"Alice Martin","away_mode_enabled":false,"away_mode_reassign":false,"has_inbox_seat":true,"team_ids":[],"team_priority_level":{}},{"type":"admin","email":"bob@example.com","id":"1000005","name":"Bob Wilson","away_mode_enabled":false,"away_mode_reassign":false,"has_inbox_seat":true,"team_ids":[],"team_priority_level":{}}]}'
headers:
Cache-Control:
- max-age=0, private, must-revalidate
Content-Type:
- application/json; charset=utf-8
Date:
- Thu, 26 Mar 2026 12:54:52 GMT
Etag:
- W/"30ffa246aba4cabfe8d217fabf428f69"
Intercom-Version:
- "2.11"
Referrer-Policy:
- strict-origin-when-cross-origin
Server:
- nginx
Status:
- 200 OK
Strict-Transport-Security:
- max-age=31556952; includeSubDomains; preload
Vary:
- Accept-Encoding
- Accept
X-Ami-Version:
- ami-050d1869df3666e48
X-Content-Type-Options:
- nosniff
X-Frame-Options:
- SAMEORIGIN
X-Intercom-Version:
- 84c7300b1acdba8bde4ff55a5b497e18b5f93395
X-Request-Id:
- 003s581hrq58akdvapk0
X-Request-Queueing:
- "0"
X-Runtime:
- "0.150437"
X-Xss-Protection:
- 1; mode=block
status: 200 OK
code: 200
duration: 430.684583ms

View File

@@ -0,0 +1,66 @@
---
version: 2
interactions:
- id: 0
request:
proto: HTTP/1.1
proto_major: 1
proto_minor: 1
content_length: 323
host: api.linear.app
body: '{"query":"\nquery AccessReviewLinearUsers($after: String) {\n users(first: 100, after: $after) {\n nodes {\n id\n email\n name\n active\n admin\n guest\n lastSeen\n createdAt\n }\n pageInfo {\n hasNextPage\n endCursor\n }\n }\n}\n","variables":{"after":null}}'
headers:
Accept:
- application/json
Content-Type:
- application/json
url: https://api.linear.app/graphql
method: POST
response:
proto: HTTP/2.0
proto_major: 2
proto_minor: 0
content_length: -1
uncompressed: true
body: |
{"data":{"users":{"nodes":[{"id":"00000001-0000-0000-0000-000000000001","email":"bot-integration@linear.linear.app","name":"Linear","active":true,"admin":false,"guest":false,"lastSeen":"2026-03-24T17:27:44.095Z","createdAt":"2026-03-24T17:27:43.979Z"},{"id":"00000001-0000-0000-0000-000000000002","email":"bot-cursor@oauthapp.linear.app","name":"Cursor","active":true,"admin":false,"guest":false,"lastSeen":"2026-03-17T14:02:50.975Z","createdAt":"2026-03-17T14:02:50.855Z"},{"id":"00000001-0000-0000-0000-000000000003","email":"jane@example.com","name":"Jane Doe","active":true,"admin":false,"guest":false,"lastSeen":"2026-03-09T12:49:53.331Z","createdAt":"2025-01-20T09:12:11.959Z"},{"id":"00000001-0000-0000-0000-000000000004","email":"john@example.com","name":"John Smith","active":true,"admin":true,"guest":false,"lastSeen":"2026-03-26T12:33:50.957Z","createdAt":"2024-07-01T13:19:57.427Z"}],"pageInfo":{"hasNextPage":false,"endCursor":"00000001-0000-0000-0000-000000000004"}}}}
headers:
Alt-Svc:
- h3=":443"; ma=86400
Cache-Control:
- no-store
Cf-Cache-Status:
- DYNAMIC
Cf-Ray:
- 9e264c7e18b111f4-CDG
Content-Type:
- application/json; charset=utf-8
Date:
- Thu, 26 Mar 2026 12:53:32 GMT
Etag:
- W/"41d-Oqy6gPJu1qx5Quzm0QLcNk/BcEY"
Server:
- cloudflare
Vary:
- Accept-Encoding
Via:
- 1.1 google
X-Complexity:
- "300"
X-Ratelimit-Complexity-Limit:
- "3000000"
X-Ratelimit-Complexity-Remaining:
- "2999700"
X-Ratelimit-Complexity-Reset:
- "1774533212671"
X-Ratelimit-Requests-Limit:
- "5000"
X-Ratelimit-Requests-Remaining:
- "4999"
X-Ratelimit-Requests-Reset:
- "1774533212671"
X-Request-Id:
- 9e264c7f056511f4-CDG
status: 200 OK
code: 200
duration: 240.856ms

View File

@@ -0,0 +1,69 @@
---
version: 2
interactions:
- id: 0
request:
proto: HTTP/1.1
proto_major: 1
proto_minor: 1
content_length: 0
host: api.notion.com
form:
page_size:
- "100"
headers:
Accept:
- application/json
Notion-Version:
- "2022-06-28"
url: https://api.notion.com/v1/users?page_size=100
method: GET
response:
proto: HTTP/2.0
proto_major: 2
proto_minor: 0
content_length: -1
uncompressed: true
body: '{"object":"list","results":[{"object":"user","id":"00000001-0000-0000-0000-000000000001","name":"Alice Martin","avatar_url":null,"type":"person","person":{"email":"alice@example.com"}},{"object":"user","id":"00000001-0000-0000-0000-000000000002","name":"Carol Davis","avatar_url":null,"type":"person","person":{"email":"carol@example.com"}},{"object":"user","id":"00000001-0000-0000-0000-000000000003","name":"John Smith","avatar_url":"","type":"person","person":{"email":"john@example.com"}},{"object":"user","id":"00000001-0000-0000-0000-000000000004","name":"Jane Doe","avatar_url":"","type":"person","person":{"email":"jane@example.com"}},{"object":"user","id":"00000001-0000-0000-0000-000000000005","name":"Acme-integration","avatar_url":"","type":"bot","bot":{}},{"object":"user","id":"00000001-0000-0000-0000-000000000006","name":"n8n","avatar_url":null,"type":"bot","bot":{"owner":{"type":"workspace","workspace":true},"workspace_name":"Acme Corp","workspace_id":"00000001-0000-0000-0000-000000000007","workspace_limits":{"max_file_upload_size_in_bytes":5368709120}}},{"object":"user","id":"00000001-0000-0000-0000-000000000008","name":"Notion MCP","avatar_url":"","type":"bot","bot":{}}],"next_cursor":null,"has_more":false,"type":"user","user":{},"request_id":"d5d5c5a0-f878-4ef7-bd30-adb4487ffc7c"}'
headers:
Alt-Svc:
- h3=":443"; ma=86400
Cf-Cache-Status:
- DYNAMIC
Cf-Ray:
- 9e266c0798647a6e-CDG
Content-Security-Policy:
- default-src 'none'
Content-Type:
- application/json; charset=utf-8
Date:
- Thu, 26 Mar 2026 13:15:04 GMT
Etag:
- W/"6f0-og1hHr9nu7Wa4h56JWNgxaLRfmE"
Referrer-Policy:
- strict-origin-when-cross-origin
Server:
- cloudflare
Set-Cookie:
- __cf_bm=PVMwsYfB62uKcpEsG3V1HzbE.xOqBPReNn8aOFie8AA-1774530904.25279-1.0.1.1-ldfHtUZ4_QqMrAC2dGUWQqZHhJw0IAYehliPDKVCxokJqrAyLXX5WrKl3MSte2nb2RU3gmcBIWbzehtXpQtGl6O3sZDuu8kxj8HULQxfyoaF9ObyS_ak5KdaxQw60WQf; HttpOnly; Secure; Path=/; Domain=notion.com; Expires=Thu, 26 Mar 2026 13:45:04 GMT
Strict-Transport-Security:
- max-age=31536000; includeSubDomains; preload
Vary:
- Accept-Encoding
X-Content-Type-Options:
- nosniff
X-Dns-Prefetch-Control:
- "off"
X-Download-Options:
- noopen
X-Frame-Options:
- SAMEORIGIN
X-Notion-Request-Id:
- d5d5c5a0-f878-4ef7-bd30-adb4487ffc7c
X-Permitted-Cross-Domain-Policies:
- none
X-Xss-Protection:
- "0"
status: 200 OK
code: 200
duration: 401.460042ms

View File

@@ -0,0 +1,83 @@
---
version: 2
interactions:
- id: 0
request:
proto: HTTP/1.1
proto_major: 1
proto_minor: 1
content_length: 0
host: api.openai.com
form:
limit:
- "100"
headers:
Accept:
- application/json
url: https://api.openai.com/v1/organization/users?limit=100
method: GET
response:
proto: HTTP/2.0
proto_major: 2
proto_minor: 0
content_length: -1
uncompressed: true
body: |-
{
"object": "list",
"data": [
{
"id": "user-aaaaaaaaaaaaaaaaaaaaaaaa",
"object": "organization.user",
"added_at": 1670256043,
"email": "john@example.com",
"name": "John Smith",
"role": "owner"
},
{
"id": "user-bbbbbbbbbbbbbbbbbbbbbbbb",
"object": "organization.user",
"added_at": 1734424828,
"email": "jane@example.com",
"name": "Jane Doe",
"role": "reader"
}
],
"first_id": "user-aaaaaaaaaaaaaaaaaaaaaaaa",
"has_more": false,
"last_id": "user-bbbbbbbbbbbbbbbbbbbbbbbb"
}
headers:
Alt-Svc:
- h3=":443"; ma=86400
Cf-Cache-Status:
- DYNAMIC
Cf-Ray:
- 9e264eaaae05343a-CDG
Content-Type:
- application/json
Date:
- Thu, 26 Mar 2026 12:55:02 GMT
Openai-Organization:
- acme-corp
Openai-Processing-Ms:
- "349"
Openai-Project:
- proj_aaaabbbbccccddddeeeeeeee
Openai-Version:
- "2020-10-01"
Server:
- cloudflare
Set-Cookie:
- __cf_bm=OJPi1FQ3aQDosdow4APC.IrgvoFoniXhs7yLPvcgzEw-1774529701.5408235-1.0.1.1-1yRB5GUWfOrogA6r7_dfUryKqwAVzgcuryL2S2fsII1iUganS7LKKhscf9JADsEDaIn6UHXk9hxfH8P3Ue0ixIaVDSlZWf30CEPCJDI6nuiN.1YHejTkmH3llHLkfuLp; HttpOnly; Secure; Path=/; Domain=api.openai.com; Expires=Thu, 26 Mar 2026 13:25:02 GMT
Strict-Transport-Security:
- max-age=31536000; includeSubDomains; preload
X-Content-Type-Options:
- nosniff
X-Openai-Proxy-Wasm:
- v0.1
X-Request-Id:
- 097c2db1-ecaa-44fe-a7bc-3b2b528acae4
status: 200 OK
code: 200
duration: 529.968583ms

View File

@@ -0,0 +1,58 @@
---
version: 2
interactions:
- id: 0
request:
proto: HTTP/1.1
proto_major: 1
proto_minor: 1
content_length: 0
host: api.resend.com
headers:
Accept:
- application/json
url: https://api.resend.com/api-keys
method: GET
response:
proto: HTTP/2.0
proto_major: 2
proto_minor: 0
content_length: -1
uncompressed: true
body: '{"object":"list","has_more":false,"data":[{"id":"00000001-0000-0000-0000-000000000001","name":"test_key","created_at":"2026-03-26 12:18:58.314592+00","last_used_at":null},{"id":"00000001-0000-0000-0000-000000000002","name":"production","created_at":"2025-11-03 12:33:51.313755+00","last_used_at":"2026-03-05 18:57:57.73093+00"}]}'
headers:
Cf-Cache-Status:
- DYNAMIC
Cf-Ray:
- 9e264e7a8c8a024f-CDG
Content-Security-Policy:
- default-src 'none'; frame-ancestors 'none'
Content-Type:
- application/json; charset=utf-8
Date:
- Thu, 26 Mar 2026 12:54:53 GMT
Etag:
- W/"146-DfTVyEh9Ui32i8/Mql/EiL8sQdI"
Permissions-Policy:
- camera=(), microphone=(), geolocation=(), payment=()
Ratelimit-Limit:
- "5"
Ratelimit-Policy:
- 5;w=1
Ratelimit-Remaining:
- "4"
Ratelimit-Reset:
- "1"
Referrer-Policy:
- strict-origin-when-cross-origin
Server:
- cloudflare
Strict-Transport-Security:
- max-age=63072000; includeSubDomains
X-Content-Type-Options:
- nosniff
X-Frame-Options:
- DENY
status: 200 OK
code: 200
duration: 192.141167ms

View File

@@ -0,0 +1,81 @@
---
version: 2
interactions:
- id: 0
request:
proto: HTTP/1.1
proto_major: 1
proto_minor: 1
content_length: 0
host: sentry.io
url: https://sentry.io/api/0/organizations/acme-corp/members/
method: GET
response:
proto: HTTP/2.0
proto_major: 2
proto_minor: 0
content_length: -1
uncompressed: true
body: '[{"id":"100001","email":"john@example.com","name":"john@example.com","user":{"id":"1000001","name":"john@example.com","username":"john@example.com","email":"john@example.com","avatarUrl":"","isActive":true,"hasPasswordAuth":false,"isManaged":false,"dateJoined":"2024-10-18T09:11:59.290806Z","lastLogin":"2026-03-16T11:30:17.761122Z","has2fa":false,"lastActive":"2026-03-26T12:13:40.025328Z","isSuperuser":false,"isStaff":false,"emails":[],"experiments":{},"avatar":{"avatarType":"letter_avatar","avatarUuid":null,"avatarUrl":null}},"orgRole":"owner","pending":false,"expired":false,"flags":{"idp:provisioned":false,"idp:role-restricted":false,"sso:linked":false,"sso:invalid":false,"member-limit:restricted":false,"partnership:restricted":false},"dateCreated":"2024-10-18T09:12:00.476934Z","inviteStatus":"approved","inviterName":null,"role":"owner","roleName":"Owner"},{"id":"100002","email":"jane@example.com","name":"Jane Doe","user":{"id":"1000002","name":"Jane Doe","username":"jane@example.com","email":"jane@example.com","avatarUrl":"","isActive":true,"hasPasswordAuth":true,"isManaged":false,"dateJoined":"2025-04-01T16:34:31.237067Z","lastLogin":"2025-11-27T16:37:09.123872Z","has2fa":false,"lastActive":"2025-11-28T14:28:23.938416Z","isSuperuser":false,"isStaff":false,"emails":[],"experiments":{},"avatar":{"avatarType":"letter_avatar","avatarUuid":null,"avatarUrl":null}},"orgRole":"member","pending":false,"expired":false,"flags":{"idp:provisioned":false,"idp:role-restricted":false,"sso:linked":false,"sso:invalid":false,"member-limit:restricted":false,"partnership:restricted":false},"dateCreated":"2025-11-07T14:54:57.672332Z","inviteStatus":"approved","inviterName":"john@example.com","role":"member","roleName":"Member"}]'
headers:
Access-Control-Allow-Headers:
- X-Sentry-Auth, X-Requested-With, Origin, Accept, Content-Type, Authentication, Authorization, Content-Encoding, sentry-trace, baggage, X-CSRFToken
Access-Control-Allow-Methods:
- GET, POST, HEAD, OPTIONS
Access-Control-Allow-Origin:
- '*'
Access-Control-Expose-Headers:
- X-Sentry-Error, X-Sentry-Direct-Hit, X-Hits, X-Max-Hits, Endpoint, Retry-After, Link
Allow:
- GET, POST, HEAD, OPTIONS
Alt-Svc:
- h3=":443"; ma=2592000,h3-29=":443"; ma=2592000
Content-Language:
- en
Content-Security-Policy:
- 'base-uri ''none''; style-src * ''unsafe-inline''; frame-ancestors ''self'' *.sentry.io; font-src * data:; connect-src ''self'' *.algolia.net *.algolianet.com *.algolia.io sentry.io *.sentry.io s1.sentry-cdn.com o1.ingest.sentry.io api2.amplitude.com app.pendo.io data.pendo.io reload.getsentry.net t687h3m0nh65.statuspage.io sentry.zendesk.com ekr.zdassets.com maps.googleapis.com; object-src ''none''; frame-src app.pendo.io demo.arcade.software js.stripe.com sentry.io ''self''; script-src ''self'' ''unsafe-inline'' ''report-sample'' s1.sentry-cdn.com js.sentry-cdn.com browser.sentry-cdn.com statuspage-production.s3.amazonaws.com static.zdassets.com aui-cdn.atlassian.com connect-cdn.atl-paas.net js.stripe.com ''strict-dynamic'' cdn.pendo.io data.pendo.io pendo-io-static.storage.googleapis.com pendo-static-5634074999128064.storage.googleapis.com; media-src *; default-src ''none''; img-src * blob: data:; worker-src blob:; report-uri https://o1.ingest.sentry.io/api/54785/security/?sentry_key=f724a8a027db45f5b21507e7142ff78e&sentry_release=8bd10e6776717c63689632c2a4b7de878fbfe6c2'
Content-Type:
- application/json
Cross-Origin-Opener-Policy-Report-Only:
- same-origin; report-to="coop-endpoint"
Date:
- Thu, 26 Mar 2026 12:53:44 GMT
Link:
- <https://sentry.io/api/0/organizations/acme-corp/members/?&cursor=100:-1:1>; rel="previous"; results="false"; cursor="100:-1:1", <https://sentry.io/api/0/organizations/acme-corp/members/?&cursor=100:1:0>; rel="next"; results="false"; cursor="100:1:0"
Report-To:
- '{"group":"coop-endpoint","max_age":86400,"endpoints":[{"url":"https://sentry-coop-302178938983.us-central1.run.app/coop"}]}'
Server:
- nginx
Strict-Transport-Security:
- max-age=31536000; includeSubDomains; preload
Vary:
- Accept-Encoding,Accept-Language, Cookie
Via:
- 1.1 google
X-Content-Type-Options:
- nosniff
X-Envoy-Attempt-Count:
- "1"
X-Envoy-Upstream-Service-Time:
- "861"
X-Frame-Options:
- deny
X-Sentry-Proxy-Url:
- http://sentry-rpc-de.psc.control.sentry.internal:8999/api/0/organizations/acme-corp/members/
X-Sentry-Rate-Limit-Concurrentlimit:
- "25"
X-Sentry-Rate-Limit-Concurrentremaining:
- "24"
X-Sentry-Rate-Limit-Limit:
- "40"
X-Sentry-Rate-Limit-Remaining:
- "39"
X-Sentry-Rate-Limit-Reset:
- "1774529624"
X-Served-By:
- frontend-default-694c567cbb-w99ht
X-Xss-Protection:
- 1; mode=block
status: 200 OK
code: 200
duration: 1.024781959s

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,56 @@
---
version: 2
interactions:
- id: 0
request:
proto: HTTP/1.1
proto_major: 1
proto_minor: 1
content_length: 0
host: api.supabase.com
headers:
Accept:
- application/json
url: https://api.supabase.com/v1/organizations/acme-corp/members
method: GET
response:
proto: HTTP/2.0
proto_major: 2
proto_minor: 0
content_length: -1
uncompressed: true
body: '[{"user_id":"a1b2c3d4-e5f6-7890-abcd-ef1234567890","user_name":"jdoe","email":"jdoe@example.com","role_name":"Owner","mfa_enabled":false}]'
headers:
Access-Control-Allow-Credentials:
- "true"
Access-Control-Expose-Headers:
- x-connection-encrypted,x-forwarded-for,user-agent,CF-Connecting-IP,Retry-After
Cf-Cache-Status:
- DYNAMIC
Cf-Ray:
- 000000000000000000-XXX
Content-Type:
- application/json; charset=utf-8
Date:
- Thu, 26 Mar 2026 13:18:24 GMT
Etag:
- W/"94-2d3haQkIE5319yeX5GL6PU8Mh2o"
Server:
- cloudflare
Set-Cookie:
- __cf_bm=REDACTED; HttpOnly; Secure; Path=/; Domain=supabase.com; Expires=Thu, 26 Mar 2026 13:48:24 GMT
Strict-Transport-Security:
- max-age=31536000; includeSubDomains; preload
Vary:
- Origin
X-Powered-By:
- Express
X-Ratelimit-Limit:
- "120"
X-Ratelimit-Remaining:
- "119"
X-Ratelimit-Reset:
- "60"
status: 200 OK
code: 200
duration: 156.602041ms

View File

@@ -0,0 +1,154 @@
---
version: 2
interactions:
- id: 0
request:
proto: HTTP/1.1
proto_major: 1
proto_minor: 1
content_length: 0
host: api.tally.so
headers:
Accept:
- application/json
url: https://api.tally.so/organizations/wvBzxD/users
method: GET
response:
proto: HTTP/2.0
proto_major: 2
proto_minor: 0
content_length: -1
uncompressed: true
body: '[{"id":"xA1bCD","firstName":"Alice","lastName":"Johnson","email":"alice@example.com","avatarUrl":"","isBlocked":false,"isDeleted":false,"timezone":"Europe/Paris","isUnknownDeviceVerificationDisabled":false,"createdAt":"2025-10-24T14:51:36.000Z","updatedAt":"2026-03-26T13:54:30.000Z","organizationId":"org001","fullName":"Alice Johnson","ssoIsConnectedWithGoogle":true,"ssoIsConnectedWithApple":false,"hasPasswordSet":true,"authenticationMethodsCount":2,"hasTwoFactorEnabled":true,"emailDomain":null},{"id":"yB2cDE","firstName":"Bob","lastName":"Smith","email":"bob@example.com","avatarUrl":"","isBlocked":false,"isDeleted":false,"timezone":"Europe/Paris","isUnknownDeviceVerificationDisabled":false,"createdAt":"2025-10-25T16:48:52.000Z","updatedAt":"2026-03-23T11:42:00.000Z","organizationId":"org001","fullName":"Bob Smith","ssoIsConnectedWithGoogle":true,"ssoIsConnectedWithApple":false,"hasPasswordSet":false,"authenticationMethodsCount":1,"hasTwoFactorEnabled":false,"emailDomain":null},{"id":"zC3dEF","firstName":"Carol","lastName":"Williams","email":"carol@example.com","avatarUrl":"","isBlocked":false,"isDeleted":false,"timezone":"Europe/Paris","isUnknownDeviceVerificationDisabled":false,"createdAt":"2025-12-01T13:38:59.000Z","updatedAt":"2026-03-06T09:47:20.000Z","organizationId":"org001","fullName":"Carol Williams","ssoIsConnectedWithGoogle":true,"ssoIsConnectedWithApple":false,"hasPasswordSet":false,"authenticationMethodsCount":1,"hasTwoFactorEnabled":false,"emailDomain":null}]'
headers:
Access-Control-Allow-Credentials:
- "true"
Access-Control-Expose-Headers:
- Mcp-Session-Id
Cf-Cache-Status:
- DYNAMIC
Cf-Ray:
- 9e26a8307c916f02-CDG
Content-Security-Policy:
- 'default-src ''self'';base-uri ''self'';font-src ''self'' https: data:;form-action ''self'';frame-ancestors ''self'';img-src ''self'' data:;object-src ''none'';script-src ''self'';script-src-attr ''none'';style-src ''self'' https: ''unsafe-inline'';upgrade-insecure-requests'
Content-Type:
- application/json; charset=utf-8
Cross-Origin-Opener-Policy:
- same-origin
Date:
- Thu, 26 Mar 2026 13:56:08 GMT
Etag:
- W/"f19-hbZ8t5Tve+qpDeG+/EcW1dXDiZM"
Nel:
- '{"report_to":"cf-nel","success_fraction":0.0,"max_age":604800}'
Origin-Agent-Cluster:
- ?1
Referrer-Policy:
- no-referrer
Report-To:
- '{"group":"cf-nel","max_age":604800,"endpoints":[{"url":"https://a.nel.cloudflare.com/report/v4?s=CocRUYJt6j9oAYgflV089%2BvXAR3wD10c6qleEoXbEMs%2FxN1a1DYwTeJ08xXieR%2F0nRHSBKZVVCUQLGiMHYrKJbCjfkSUlWLf%2BTR%2BW9a4V%2FhvMBzLQXaQsRb1ii1SIg%3D%3D"}]}'
Server:
- cloudflare
Strict-Transport-Security:
- max-age=31536000; includeSubDomains
Vary:
- Origin
X-Cloud-Trace-Context:
- 7337a79cb9f6d2c654df69076505ff5a
X-Content-Type-Options:
- nosniff
X-Dns-Prefetch-Control:
- "off"
X-Download-Options:
- noopen
X-Frame-Options:
- SAMEORIGIN
X-Permitted-Cross-Domain-Policies:
- none
X-Ratelimit-Limit:
- "100"
X-Ratelimit-Remaining:
- "95"
X-Ratelimit-Reset:
- "1774533375"
X-Xss-Protection:
- "0"
status: 200 OK
code: 200
duration: 77.593208ms
- id: 1
request:
proto: HTTP/1.1
proto_major: 1
proto_minor: 1
content_length: 0
host: api.tally.so
headers:
Accept:
- application/json
url: https://api.tally.so/organizations/wvBzxD/invites
method: GET
response:
proto: HTTP/2.0
proto_major: 2
proto_minor: 0
content_length: 2
body: '[]'
headers:
Access-Control-Allow-Credentials:
- "true"
Access-Control-Expose-Headers:
- Mcp-Session-Id
Cf-Cache-Status:
- DYNAMIC
Cf-Ray:
- 9e26a830cccb6f02-CDG
Content-Length:
- "2"
Content-Security-Policy:
- 'default-src ''self'';base-uri ''self'';font-src ''self'' https: data:;form-action ''self'';frame-ancestors ''self'';img-src ''self'' data:;object-src ''none'';script-src ''self'';script-src-attr ''none'';style-src ''self'' https: ''unsafe-inline'';upgrade-insecure-requests'
Content-Type:
- application/json; charset=utf-8
Cross-Origin-Opener-Policy:
- same-origin
Date:
- Thu, 26 Mar 2026 13:56:08 GMT
Etag:
- W/"2-l9Fw4VUO7kr8CvBlt4zaMCqXZ0w"
Nel:
- '{"report_to":"cf-nel","success_fraction":0.0,"max_age":604800}'
Origin-Agent-Cluster:
- ?1
Referrer-Policy:
- no-referrer
Report-To:
- '{"group":"cf-nel","max_age":604800,"endpoints":[{"url":"https://a.nel.cloudflare.com/report/v4?s=vfTMmGB%2FitCP4gkTcfhy9vOYzJZbTYp4a0km1df34sZL3TfZ17vXbuv4kl5nEwXSPgHv1si5IF8qaUW1z70vzxLUnCqaF0A7%2Fi6DgBVz1Xv%2BwAGOIUHAA6uyq2%2B76A%3D%3D"}]}'
Server:
- cloudflare
Strict-Transport-Security:
- max-age=31536000; includeSubDomains
Vary:
- Origin
X-Cloud-Trace-Context:
- 38b42db0f009bb00781397cecba8b838
X-Content-Type-Options:
- nosniff
X-Dns-Prefetch-Control:
- "off"
X-Download-Options:
- noopen
X-Frame-Options:
- SAMEORIGIN
X-Permitted-Cross-Domain-Policies:
- none
X-Ratelimit-Limit:
- "100"
X-Ratelimit-Remaining:
- "94"
X-Ratelimit-Reset:
- "1774533375"
X-Xss-Protection:
- "0"
status: 200 OK
code: 200
duration: 48.255292ms

View File

@@ -0,0 +1,99 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.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 (
"net/http"
"os"
"testing"
"gopkg.in/dnaeon/go-vcr.v4/pkg/cassette"
"gopkg.in/dnaeon/go-vcr.v4/pkg/recorder"
)
// newRecorder creates a go-vcr recorder for the given cassette path. When
// the env var is non-empty the recorder runs in record mode, otherwise
// it replays from the committed cassette. A BeforeSave hook strips the
// Authorization header so tokens are never persisted.
func newRecorder(t *testing.T, cassettePath string, envVar string) *recorder.Recorder {
t.Helper()
mode := recorder.ModeReplayOnly
if os.Getenv(envVar) != "" {
mode = recorder.ModeRecordOnly
}
rec, err := recorder.New(
cassettePath,
recorder.WithMode(mode),
recorder.WithSkipRequestLatency(true),
recorder.WithHook(func(i *cassette.Interaction) error {
i.Request.Headers.Del("Authorization")
return nil
}, recorder.BeforeSaveHook),
)
if err != nil {
if mode == recorder.ModeReplayOnly {
t.Skipf("cassette not found (record with %s env var): %v", envVar, err)
}
t.Fatalf("cannot create vcr recorder: %v", err)
}
t.Cleanup(func() {
if err := rec.Stop(); err != nil {
t.Errorf("cannot stop vcr recorder: %v", err)
}
})
return rec
}
// authRoundTripper wraps a transport and injects an Authorization header
// into each request. The authValue is set as-is (caller provides "Bearer xxx"
// or a raw API key depending on the provider).
type authRoundTripper struct {
authValue string
transport http.RoundTripper
}
func (rt *authRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
if rt.authValue != "" {
req.Header.Set("Authorization", rt.authValue)
}
return rt.transport.RoundTrip(req)
}
// bearerAuth returns "Bearer <token>" if the token is non-empty, or "" otherwise.
func bearerAuth(token string) string {
if token == "" {
return ""
}
return "Bearer " + token
}
// newVCRClient creates an *http.Client backed by the recorder's transport,
// with an optional Authorization header injected into requests (for recording
// mode). The authValue should be the complete header value, e.g.
// "Bearer xxx" or a raw API key like "lin_api_xxx".
func newVCRClient(rec *recorder.Recorder, authValue string) *http.Client {
transport := rec.GetDefaultClient().Transport
if authValue != "" {
transport = &authRoundTripper{
authValue: authValue,
transport: transport,
}
}
return &http.Client{Transport: transport}
}