Add 13 access-review driver implementations → Dispatch new providers in review engine and name worker

- Add 13 access-review driver implementations
- Add VCR-driven driver tests with synthetic cassettes
- Guard cassettes against non-synthetic email leaks
- Dispatch new providers in review engine and name worker

Signed-off-by: Aurélien Sibiril <81782+aureliensibiril@users.noreply.github.com>
This commit is contained in:
Aurélien Sibiril
2026-05-17 17:22:48 +02:00
parent 0147acd9f0
commit 1532e94eb1
43 changed files with 3345 additions and 0 deletions

View File

@@ -388,6 +388,48 @@ func (s AccessSourceService) ConfigureAccessSource(
}); err != nil { }); err != nil {
return fmt.Errorf("cannot set sentry settings: %w", err) return fmt.Errorf("cannot set sentry settings: %w", err)
} }
case coredata.ConnectorProviderGitLab:
if err := dbConnector.SetSettings(&coredata.GitLabConnectorSettings{
GroupID: req.OrganizationSlug,
}); err != nil {
return fmt.Errorf("cannot set gitlab settings: %w", err)
}
case coredata.ConnectorProviderBitbucket:
if err := dbConnector.SetSettings(&coredata.BitbucketConnectorSettings{
Workspace: req.OrganizationSlug,
}); err != nil {
return fmt.Errorf("cannot set bitbucket settings: %w", err)
}
case coredata.ConnectorProviderHeroku:
if err := dbConnector.SetSettings(&coredata.HerokuConnectorSettings{
TeamID: req.OrganizationSlug,
}); err != nil {
return fmt.Errorf("cannot set heroku settings: %w", err)
}
case coredata.ConnectorProviderAsana:
if err := dbConnector.SetSettings(&coredata.AsanaConnectorSettings{
WorkspaceGID: req.OrganizationSlug,
}); err != nil {
return fmt.Errorf("cannot set asana settings: %w", err)
}
case coredata.ConnectorProviderSnyk:
if err := dbConnector.SetSettings(&coredata.SnykConnectorSettings{
OrgID: req.OrganizationSlug,
}); err != nil {
return fmt.Errorf("cannot set snyk settings: %w", err)
}
case coredata.ConnectorProviderNetlify:
if err := dbConnector.SetSettings(&coredata.NetlifyConnectorSettings{
AccountSlug: req.OrganizationSlug,
}); err != nil {
return fmt.Errorf("cannot set netlify settings: %w", err)
}
case coredata.ConnectorProviderClickUp:
if err := dbConnector.SetSettings(&coredata.ClickUpConnectorSettings{
TeamID: req.OrganizationSlug,
}); err != nil {
return fmt.Errorf("cannot set clickup settings: %w", err)
}
default: default:
return fmt.Errorf("cannot configure access source: provider %s does not support organization configuration", dbConnector.Provider) return fmt.Errorf("cannot configure access source: provider %s does not support organization configuration", dbConnector.Provider)
} }

View File

@@ -0,0 +1,132 @@
// 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"
)
// AsanaDriver fetches users for a given Asana workspace via REST API
// using a pre-authenticated HTTP client (Bearer token). Pagination is
// driven by the body field `next_page.uri`.
//
// The user object exposes very little: gid, name, email. There is no
// role / MFA / last-login signal. Active is derived defensively from
// the presence of an email — Asana hides the email field for
// deactivated or privacy-protected users.
type AsanaDriver struct {
httpClient *http.Client
workspaceGID string
}
var _ Driver = (*AsanaDriver)(nil)
func NewAsanaDriver(httpClient *http.Client, workspaceGID string) *AsanaDriver {
return &AsanaDriver{
httpClient: &http.Client{
Transport: &retryRoundTripper{
next: httpClient.Transport,
maxRetries: 3,
},
},
workspaceGID: workspaceGID,
}
}
type asanaUser struct {
GID string `json:"gid"`
Name string `json:"name"`
Email string `json:"email"`
}
type asanaUsersPage struct {
Data []asanaUser `json:"data"`
NextPage *struct {
URI string `json:"uri"`
} `json:"next_page"`
}
func (d *AsanaDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error) {
var records []AccountRecord
next := fmt.Sprintf(
"https://app.asana.com/api/1.0/workspaces/%s/users?opt_fields=email,name&limit=100",
d.workspaceGID,
)
for range maxPaginationPages {
page, err := d.queryUsers(ctx, next)
if err != nil {
return nil, err
}
for _, u := range page.Data {
record := AccountRecord{
Email: u.Email,
FullName: u.Name,
ExternalID: u.GID,
MFAStatus: coredata.MFAStatusUnknown,
AuthMethod: coredata.AccessEntryAuthMethodUnknown,
AccountType: coredata.AccessEntryAccountTypeUser,
}
// Asana hides email for deactivated / privacy-protected users.
// We treat missing email as a defensive Active=false signal.
if u.Email == "" {
active := false
record.Active = &active
}
records = append(records, record)
}
if page.NextPage == nil || page.NextPage.URI == "" {
return records, nil
}
next = page.NextPage.URI
}
return nil, fmt.Errorf("cannot list all asana accounts: %w", ErrPaginationLimitReached)
}
func (d *AsanaDriver) queryUsers(ctx context.Context, url string) (*asanaUsersPage, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("cannot create asana 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 asana users request: %w", err)
}
defer func() { _ = httpResp.Body.Close() }()
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
return nil, fmt.Errorf("cannot fetch asana users: unexpected status %d", httpResp.StatusCode)
}
var page asanaUsersPage
if err := json.NewDecoder(httpResp.Body).Decode(&page); err != nil {
return nil, fmt.Errorf("cannot decode asana users response: %w", err)
}
return &page, nil
}

View File

@@ -0,0 +1,51 @@
// 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 TestAsanaDriver(t *testing.T) {
t.Parallel()
rec := newRecorder(t, "testdata/asana", "ASANA_TOKEN")
client := newVCRClient(rec, bearerAuth(os.Getenv("ASANA_TOKEN")))
workspaceGID := os.Getenv("ASANA_WORKSPACE_GID")
if workspaceGID == "" {
workspaceGID = "9999999"
}
driver := NewAsanaDriver(client, workspaceGID)
records, err := driver.ListAccounts(context.Background())
require.NoError(t, err)
require.NotEmpty(t, records)
r := records[0]
assert.NotEmpty(t, r.ExternalID)
assert.NotEmpty(t, r.FullName)
assert.NotEmpty(t, r.Email)
// Records without an email should be marked Active=false.
require.Len(t, records, 2)
require.NotNil(t, records[1].Active)
assert.False(t, *records[1].Active)
}

View File

@@ -0,0 +1,130 @@
// 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"
)
// BitbucketDriver fetches workspace members from the Bitbucket Cloud
// REST API using a pre-authenticated HTTP client (Bearer token).
//
// The Bitbucket member object exposes very little: account_id, display
// name, optional email (often hidden by privacy), nickname. There is no
// role / MFA / last-login data available, so those fields are left at
// their zero defaults / nil / Unknown.
type BitbucketDriver struct {
httpClient *http.Client
workspace string
}
var _ Driver = (*BitbucketDriver)(nil)
func NewBitbucketDriver(httpClient *http.Client, workspace string) *BitbucketDriver {
return &BitbucketDriver{
httpClient: &http.Client{
Transport: &retryRoundTripper{
next: httpClient.Transport,
maxRetries: 3,
},
},
workspace: workspace,
}
}
type bitbucketMember struct {
User struct {
AccountID string `json:"account_id"`
DisplayName string `json:"display_name"`
Nickname string `json:"nickname"`
Email string `json:"email"`
} `json:"user"`
}
type bitbucketMembersPage struct {
Values []bitbucketMember `json:"values"`
Next string `json:"next"`
}
func (d *BitbucketDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error) {
var records []AccountRecord
next := fmt.Sprintf(
"https://api.bitbucket.org/2.0/workspaces/%s/members?fields=%%2Bvalues.user.email&pagelen=100",
d.workspace,
)
for range maxPaginationPages {
page, err := d.queryMembers(ctx, next)
if err != nil {
return nil, err
}
for _, m := range page.Values {
fullName := m.User.DisplayName
if fullName == "" {
fullName = m.User.Nickname
}
record := AccountRecord{
Email: m.User.Email,
FullName: fullName,
ExternalID: m.User.AccountID,
MFAStatus: coredata.MFAStatusUnknown,
AuthMethod: coredata.AccessEntryAuthMethodUnknown,
AccountType: coredata.AccessEntryAccountTypeUser,
}
records = append(records, record)
}
next = page.Next
if next == "" {
return records, nil
}
}
return nil, fmt.Errorf("cannot list all bitbucket accounts: %w", ErrPaginationLimitReached)
}
func (d *BitbucketDriver) queryMembers(ctx context.Context, url string) (*bitbucketMembersPage, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("cannot create bitbucket 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 bitbucket members request: %w", err)
}
defer func() { _ = httpResp.Body.Close() }()
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
return nil, fmt.Errorf("cannot fetch bitbucket members: unexpected status %d", httpResp.StatusCode)
}
var page bitbucketMembersPage
if err := json.NewDecoder(httpResp.Body).Decode(&page); err != nil {
return nil, fmt.Errorf("cannot decode bitbucket members response: %w", err)
}
return &page, 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 TestBitbucketDriver(t *testing.T) {
t.Parallel()
rec := newRecorder(t, "testdata/bitbucket", "BITBUCKET_TOKEN")
client := newVCRClient(rec, bearerAuth(os.Getenv("BITBUCKET_TOKEN")))
workspace := os.Getenv("BITBUCKET_WORKSPACE")
if workspace == "" {
workspace = "acme"
}
driver := NewBitbucketDriver(client, workspace)
records, err := driver.ListAccounts(context.Background())
require.NoError(t, err)
require.NotEmpty(t, records)
r := records[0]
assert.NotEmpty(t, r.ExternalID)
assert.NotEmpty(t, r.FullName)
assert.NotEmpty(t, r.Email)
}

View File

@@ -0,0 +1,111 @@
// 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 (
"os"
"path/filepath"
"regexp"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestCassettesUseSyntheticEmails enforces that VCR cassettes under
// testdata/ only contain emails from a controlled set of synthetic
// domains (RFC 2606 reserved + a small allowlist for OAuth bot
// identifiers in pre-existing cassettes). Real cassette recordings
// against test tenants must be scrubbed before commit; this test
// guards against accidental leakage of customer emails.
func TestCassettesUseSyntheticEmails(t *testing.T) {
t.Parallel()
emailRe := regexp.MustCompile(`[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}`)
// allowedDomainSuffixes lists trailing domain fragments that are safe
// for synthetic test data. RFC 2606 reserves *.example.com / .org /
// .net / .test / .invalid / .localhost. The allowlist is intentionally
// narrow — adding to it should require maintainer review.
allowedDomainSuffixes := []string{
".example.com",
".example.org",
".example.net",
".test",
".invalid",
".localhost",
}
// allowedExactDomains lists individual domains that pre-date this
// guard and were intentionally kept (synthetic OAuth bot identifiers
// recorded against fixture tenants). Do not extend without review.
allowedExactDomains := map[string]bool{
"example.com": true,
"example.org": true,
"example.net": true,
"mail.com": true,
"contractor.example.com": true,
"alias.example.com": true,
"oauthapp.linear.app": true,
"linear.linear.app": true,
"intercom.io": true,
}
matches, err := filepath.Glob("testdata/*.yaml")
require.NoError(t, err)
require.NotEmpty(t, matches, "no cassettes found")
for _, cassette := range matches {
t.Run(filepath.Base(cassette), func(t *testing.T) {
t.Parallel()
data, err := os.ReadFile(cassette)
require.NoError(t, err)
seen := make(map[string]bool)
for _, email := range emailRe.FindAllString(string(data), -1) {
if seen[email] {
continue
}
seen[email] = true
domain := email[strings.IndexByte(email, '@')+1:]
domain = strings.TrimSuffix(domain, ".test-google-a.com")
if allowedExactDomains[domain] {
continue
}
ok := false
for _, suffix := range allowedDomainSuffixes {
if strings.HasSuffix("."+domain, suffix) || domain == strings.TrimPrefix(suffix, ".") {
ok = true
break
}
}
assert.Truef(
t,
ok,
"cassette %s contains email %q with non-synthetic domain %q; "+
"either replace with a synthetic *.example.com address or "+
"add the domain to allowedExactDomains in cassette_safety_test.go "+
"with a justification",
filepath.Base(cassette), email, domain,
)
}
})
}
}

View File

@@ -0,0 +1,154 @@
// 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"
)
// ClickUpDriver fetches workspace ("team") members from the ClickUp
// REST API using a pre-authenticated HTTP client (Bearer token). The
// team endpoint returns the full member list inline in a single
// response — no pagination is performed.
//
// ClickUp does not issue refresh tokens; the existing RefreshableClient
// falls back to a non-refreshing client when RefreshToken == "" and the
// access source resolver re-prompts for re-authorization on 401.
type ClickUpDriver struct {
httpClient *http.Client
teamID string
}
var _ Driver = (*ClickUpDriver)(nil)
func NewClickUpDriver(httpClient *http.Client, teamID string) *ClickUpDriver {
return &ClickUpDriver{
httpClient: httpClient,
teamID: teamID,
}
}
type clickupMember struct {
User struct {
ID json.Number `json:"id"`
Email string `json:"email"`
Username string `json:"username"`
Role int `json:"role"`
LastActive string `json:"last_active"`
} `json:"user"`
InvitePending *bool `json:"invite_pending"`
}
type clickupTeamResponse struct {
Team struct {
Members []clickupMember `json:"members"`
} `json:"team"`
}
func (d *ClickUpDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error) {
url := fmt.Sprintf("https://api.clickup.com/api/v2/team/%s", d.teamID)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("cannot create clickup team request: %w", err)
}
req.Header.Set("Accept", "application/json")
httpResp, err := d.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("cannot execute clickup team request: %w", err)
}
defer func() { _ = httpResp.Body.Close() }()
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
return nil, fmt.Errorf("cannot fetch clickup team: unexpected status %d", httpResp.StatusCode)
}
var resp clickupTeamResponse
if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil {
return nil, fmt.Errorf("cannot decode clickup team response: %w", err)
}
records := make([]AccountRecord, 0, len(resp.Team.Members))
for _, m := range resp.Team.Members {
role := clickupRoleLabel(m.User.Role)
isAdmin := m.User.Role == 1 || m.User.Role == 2
record := AccountRecord{
Email: m.User.Email,
FullName: m.User.Username,
Role: role,
IsAdmin: isAdmin,
ExternalID: m.User.ID.String(),
MFAStatus: coredata.MFAStatusUnknown,
AuthMethod: coredata.AccessEntryAuthMethodUnknown,
AccountType: coredata.AccessEntryAccountTypeUser,
}
if m.InvitePending != nil {
active := !*m.InvitePending
record.Active = &active
}
if m.User.LastActive != "" {
// ClickUp emits last_active as a Unix-millis string; fall
// back to RFC3339 if a future API change switches format.
if t, err := parseClickUpTime(m.User.LastActive); err == nil {
record.LastLogin = &t
}
}
records = append(records, record)
}
return records, nil
}
// clickupRoleLabel maps ClickUp numeric role codes to human-readable
// labels. Source: https://clickup.com/api (Team Members endpoint).
func clickupRoleLabel(role int) string {
switch role {
case 1:
return "owner"
case 2:
return "admin"
case 3:
return "member"
case 4:
return "guest"
default:
return ""
}
}
// parseClickUpTime accepts both ClickUp's Unix-millis-as-string format
// and RFC3339 timestamps so the driver remains forward-compatible.
func parseClickUpTime(raw string) (time.Time, error) {
if t, err := time.Parse(time.RFC3339, raw); err == nil {
return t, nil
}
var ms int64
if _, err := fmt.Sscanf(raw, "%d", &ms); err != nil {
return time.Time{}, fmt.Errorf("cannot parse clickup time %q: %w", raw, err)
}
return time.UnixMilli(ms).UTC(), nil
}

View File

@@ -0,0 +1,57 @@
// 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 TestClickUpDriver(t *testing.T) {
t.Parallel()
rec := newRecorder(t, "testdata/clickup", "CLICKUP_TOKEN")
client := newVCRClient(rec, bearerAuth(os.Getenv("CLICKUP_TOKEN")))
teamID := os.Getenv("CLICKUP_TEAM_ID")
if teamID == "" {
teamID = "9999999"
}
driver := NewClickUpDriver(client, teamID)
records, err := driver.ListAccounts(context.Background())
require.NoError(t, err)
require.Len(t, records, 2)
r := records[0]
assert.Equal(t, "111", r.ExternalID)
assert.Equal(t, "jane@example.com", r.Email)
assert.Equal(t, "jane.doe", r.FullName)
assert.Equal(t, "owner", r.Role)
assert.True(t, r.IsAdmin)
require.NotNil(t, r.Active)
assert.True(t, *r.Active)
require.NotNil(t, r.LastLogin)
// Pending invite -> Active=false.
require.NotNil(t, records[1].Active)
assert.False(t, *records[1].Active)
assert.Equal(t, "member", records[1].Role)
assert.False(t, records[1].IsAdmin)
}

View File

@@ -0,0 +1,135 @@
// 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"
"strings"
"go.probo.inc/probo/pkg/coredata"
)
// DeelDriver fetches people from the Deel REST API using a
// pre-authenticated HTTP client (Bearer token). Pagination is
// offset-based: increment `offset` by `limit` until the response
// `data` array is empty.
//
// Notes on data quality:
// - Active is derived from `hiring_status == "active"`. When `end_date`
// is set the worker is considered inactive regardless of hiring_status.
// - MFA and last-login are not exposed by the people endpoint.
type DeelDriver struct {
httpClient *http.Client
}
var _ Driver = (*DeelDriver)(nil)
func NewDeelDriver(httpClient *http.Client) *DeelDriver {
return &DeelDriver{httpClient: httpClient}
}
type deelPerson struct {
ID string `json:"id"`
Email string `json:"email"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
JobTitle string `json:"job_title"`
HiringStatus string `json:"hiring_status"`
StartDate string `json:"start_date"`
EndDate string `json:"end_date"`
}
type deelPeoplePage struct {
Data []deelPerson `json:"data"`
}
func (d *DeelDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error) {
var records []AccountRecord
const limit = 100
offset := 0
for range maxPaginationPages {
people, err := d.queryPeople(ctx, offset, limit)
if err != nil {
return nil, err
}
if len(people) == 0 {
return records, nil
}
for _, p := range people {
fullName := strings.TrimSpace(p.FirstName + " " + p.LastName)
active := p.HiringStatus == "active"
if p.EndDate != "" {
active = false
}
record := AccountRecord{
Email: p.Email,
FullName: fullName,
JobTitle: p.JobTitle,
Active: &active,
MFAStatus: coredata.MFAStatusUnknown,
AuthMethod: coredata.AccessEntryAuthMethodUnknown,
AccountType: coredata.AccessEntryAccountTypeUser,
ExternalID: p.ID,
}
records = append(records, record)
}
offset += limit
}
return nil, fmt.Errorf("cannot list all deel accounts: %w", ErrPaginationLimitReached)
}
func (d *DeelDriver) queryPeople(ctx context.Context, offset, limit int) ([]deelPerson, error) {
q := url.Values{}
q.Set("limit", strconv.Itoa(limit))
q.Set("offset", strconv.Itoa(offset))
endpoint := "https://api.letsdeel.com/rest/v2/people?" + q.Encode()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, fmt.Errorf("cannot create deel people request: %w", err)
}
req.Header.Set("Accept", "application/json")
httpResp, err := d.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("cannot execute deel people request: %w", err)
}
defer func() { _ = httpResp.Body.Close() }()
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
return nil, fmt.Errorf("cannot fetch deel people: unexpected status %d", httpResp.StatusCode)
}
var page deelPeoplePage
if err := json.NewDecoder(httpResp.Body).Decode(&page); err != nil {
return nil, fmt.Errorf("cannot decode deel people response: %w", err)
}
return page.Data, nil
}

View File

@@ -0,0 +1,49 @@
// 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 TestDeelDriver(t *testing.T) {
t.Parallel()
rec := newRecorder(t, "testdata/deel", "DEEL_TOKEN")
client := newVCRClient(rec, bearerAuth(os.Getenv("DEEL_TOKEN")))
driver := NewDeelDriver(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.ExternalID)
assert.NotEmpty(t, r.FullName)
assert.NotEmpty(t, r.JobTitle)
require.NotNil(t, r.Active)
assert.True(t, *r.Active)
// Inactive (or end_date set) people surface as Active=false.
require.Len(t, records, 2)
require.NotNil(t, records[1].Active)
assert.False(t, *records[1].Active)
}

View File

@@ -0,0 +1,160 @@
// 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"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/rfc5988"
)
// GitLabDriver fetches all-members of a GitLab group via REST API
// using a pre-authenticated HTTP client (Bearer token).
//
// Notes on data quality on gitlab.com SaaS:
// - `email` is often null on Free; we leave it blank when the API
// returns null. `username` is used as the FullName fallback.
// - Per-user MFA status is admin-only on gitlab.com SaaS, so MFAStatus
// is left Unknown.
// - `last_login_at` is paid-plan only via the separate /billable_members
// endpoint, so LastLogin is left nil for v1.
type GitLabDriver struct {
httpClient *http.Client
groupID string
}
var _ Driver = (*GitLabDriver)(nil)
func NewGitLabDriver(httpClient *http.Client, groupID string) *GitLabDriver {
return &GitLabDriver{
httpClient: &http.Client{
Transport: &retryRoundTripper{
next: httpClient.Transport,
maxRetries: 3,
},
},
groupID: groupID,
}
}
type gitlabMember struct {
ID int64 `json:"id"`
Username string `json:"username"`
Name string `json:"name"`
Email string `json:"email"`
State string `json:"state"`
AccessLevel int `json:"access_level"`
}
func (d *GitLabDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error) {
var records []AccountRecord
next := fmt.Sprintf(
"https://gitlab.com/api/v4/groups/%s/members/all?per_page=100",
d.groupID,
)
for range maxPaginationPages {
members, linkHeader, err := d.queryMembers(ctx, next)
if err != nil {
return nil, err
}
for _, m := range members {
fullName := m.Name
if fullName == "" {
fullName = m.Username
}
active := m.State == "active"
role := gitlabAccessLevelLabel(m.AccessLevel)
record := AccountRecord{
Email: m.Email,
FullName: fullName,
Role: role,
Active: &active,
IsAdmin: m.AccessLevel >= 50, // 50 = Owner
MFAStatus: coredata.MFAStatusUnknown,
AuthMethod: coredata.AccessEntryAuthMethodUnknown,
AccountType: coredata.AccessEntryAccountTypeUser,
ExternalID: strconv.FormatInt(m.ID, 10),
}
records = append(records, record)
}
next = rfc5988.FindByRel(linkHeader, "next")
if next == "" {
return records, nil
}
}
return nil, fmt.Errorf("cannot list all gitlab accounts: %w", ErrPaginationLimitReached)
}
func (d *GitLabDriver) queryMembers(ctx context.Context, url string) ([]gitlabMember, string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, "", fmt.Errorf("cannot create gitlab 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 gitlab members request: %w", err)
}
defer func() { _ = httpResp.Body.Close() }()
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
return nil, "", fmt.Errorf("cannot fetch gitlab members: unexpected status %d", httpResp.StatusCode)
}
var members []gitlabMember
if err := json.NewDecoder(httpResp.Body).Decode(&members); err != nil {
return nil, "", fmt.Errorf("cannot decode gitlab members response: %w", err)
}
return members, httpResp.Header.Get("Link"), nil
}
// gitlabAccessLevelLabel maps GitLab numeric access levels to human
// labels. Source: https://docs.gitlab.com/api/members/#roles
func gitlabAccessLevelLabel(level int) string {
switch level {
case 5:
return "Minimal Access"
case 10:
return "Guest"
case 15:
return "Planner"
case 20:
return "Reporter"
case 30:
return "Developer"
case 40:
return "Maintainer"
case 50:
return "Owner"
default:
return ""
}
}

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"
"os"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.probo.inc/probo/pkg/coredata"
)
func TestGitLabDriver(t *testing.T) {
t.Parallel()
rec := newRecorder(t, "testdata/gitlab", "GITLAB_TOKEN")
client := newVCRClient(rec, bearerAuth(os.Getenv("GITLAB_TOKEN")))
groupID := os.Getenv("GITLAB_GROUP_ID")
if groupID == "" {
groupID = "12345"
}
driver := NewGitLabDriver(client, groupID)
records, err := driver.ListAccounts(context.Background())
require.NoError(t, err)
require.NotEmpty(t, records)
r := records[0]
assert.NotEmpty(t, r.ExternalID)
assert.NotEmpty(t, r.FullName)
assert.NotEmpty(t, r.Role)
assert.Equal(t, coredata.MFAStatusUnknown, r.MFAStatus)
require.NotNil(t, r.Active)
assert.True(t, *r.Active)
assert.True(t, r.IsAdmin) // first record is access_level=50 (Owner)
}

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"
"time"
"go.probo.inc/probo/pkg/coredata"
)
// HerokuDriver fetches team members from the Heroku Platform API using
// a pre-authenticated HTTP client (Bearer token). Pagination is via
// Heroku's Range / Next-Range header pair (RFC 7233 style).
//
// Notes on data quality:
// - The team-members endpoint does not expose suspension state, so
// Active is left nil for v1.
// - For federated teams the IdP is the source of truth for MFA, but
// the API still reports `two_factor_authentication`. The driver
// populates MFAStatus from that field and lets the access-review
// UI surface federation context separately.
type HerokuDriver struct {
httpClient *http.Client
teamID string
}
var _ Driver = (*HerokuDriver)(nil)
func NewHerokuDriver(httpClient *http.Client, teamID string) *HerokuDriver {
return &HerokuDriver{
httpClient: &http.Client{
Transport: &retryRoundTripper{
next: httpClient.Transport,
maxRetries: 3,
},
},
teamID: teamID,
}
}
type herokuTeamMember struct {
ID string `json:"id"`
Email string `json:"email"`
Role string `json:"role"`
TwoFactorAuthentication bool `json:"two_factor_authentication"`
Federated bool `json:"federated"`
CreatedAt string `json:"created_at"`
User struct {
Email string `json:"email"`
ID string `json:"id"`
Name string `json:"name"`
} `json:"user"`
}
func (d *HerokuDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error) {
var records []AccountRecord
url := fmt.Sprintf("https://api.heroku.com/teams/%s/members", d.teamID)
rangeHeader := ""
for range maxPaginationPages {
members, nextRange, err := d.queryMembers(ctx, url, rangeHeader)
if err != nil {
return nil, err
}
for _, m := range members {
email := m.Email
if email == "" {
email = m.User.Email
}
fullName := m.User.Name
mfaStatus := coredata.MFAStatusDisabled
if m.TwoFactorAuthentication {
mfaStatus = coredata.MFAStatusEnabled
}
isAdmin := m.Role == "admin" || m.Role == "owner"
record := AccountRecord{
Email: email,
FullName: fullName,
Role: m.Role,
IsAdmin: isAdmin,
MFAStatus: mfaStatus,
AuthMethod: coredata.AccessEntryAuthMethodUnknown,
AccountType: coredata.AccessEntryAccountTypeUser,
ExternalID: email,
}
if m.CreatedAt != "" {
if t, err := time.Parse(time.RFC3339, m.CreatedAt); err == nil {
record.CreatedAt = &t
}
}
records = append(records, record)
}
if nextRange == "" {
return records, nil
}
rangeHeader = nextRange
}
return nil, fmt.Errorf("cannot list all heroku accounts: %w", ErrPaginationLimitReached)
}
func (d *HerokuDriver) queryMembers(ctx context.Context, url, rangeHeader string) ([]herokuTeamMember, string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, "", fmt.Errorf("cannot create heroku members request: %w", err)
}
req.Header.Set("Accept", "application/vnd.heroku+json; version=3")
if rangeHeader != "" {
req.Header.Set("Range", rangeHeader)
}
httpResp, err := d.httpClient.Do(req)
if err != nil {
return nil, "", fmt.Errorf("cannot execute heroku members request: %w", err)
}
defer func() { _ = httpResp.Body.Close() }()
// Heroku returns 206 Partial Content for ranged responses with more
// pages, and 200 OK for the final/only page.
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
return nil, "", fmt.Errorf("cannot fetch heroku members: unexpected status %d", httpResp.StatusCode)
}
var members []herokuTeamMember
if err := json.NewDecoder(httpResp.Body).Decode(&members); err != nil {
return nil, "", fmt.Errorf("cannot decode heroku members response: %w", err)
}
return members, httpResp.Header.Get("Next-Range"), 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"
"os"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.probo.inc/probo/pkg/coredata"
)
func TestHerokuDriver(t *testing.T) {
t.Parallel()
rec := newRecorder(t, "testdata/heroku", "HEROKU_TOKEN")
client := newVCRClient(rec, bearerAuth(os.Getenv("HEROKU_TOKEN")))
teamID := os.Getenv("HEROKU_TEAM_ID")
if teamID == "" {
teamID = "acme"
}
driver := NewHerokuDriver(client, teamID)
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.ExternalID)
assert.NotEmpty(t, r.FullName)
assert.NotEmpty(t, r.Role)
assert.Equal(t, coredata.MFAStatusEnabled, r.MFAStatus)
assert.True(t, r.IsAdmin)
require.NotNil(t, r.CreatedAt)
}

View File

@@ -0,0 +1,148 @@
// 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"
)
// LeverDriver fetches users from the Lever REST API using a
// pre-authenticated HTTP client (Bearer token from the Auth0-backed
// flow). Pagination is body-cursor based: response carries `data[]`,
// `hasNext` (bool), and `next` (cursor). The next request appends
// `?offset=<cursor>`.
//
// Notes on data quality:
// - `lastLoggedInAt` and `createdAt` are epoch milliseconds —
// defensive parse, may be null/undocumented.
// - MFA is exposed only via SCIM/SSO, not the REST API.
// - Active is derived from `deactivatedAt`: nil/missing = active.
type LeverDriver struct {
httpClient *http.Client
}
var _ Driver = (*LeverDriver)(nil)
func NewLeverDriver(httpClient *http.Client) *LeverDriver {
return &LeverDriver{
httpClient: &http.Client{
Transport: &retryRoundTripper{
next: httpClient.Transport,
maxRetries: 3,
},
},
}
}
type leverUser struct {
ID string `json:"id"`
Email string `json:"email"`
Name string `json:"name"`
AccessRole string `json:"accessRole"`
DeactivatedAt *int64 `json:"deactivatedAt"`
LastLoggedInAt *int64 `json:"lastLoggedInAt"`
CreatedAt *int64 `json:"createdAt"`
}
type leverUsersPage struct {
Data []leverUser `json:"data"`
HasNext bool `json:"hasNext"`
Next string `json:"next"`
}
func (d *LeverDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error) {
var records []AccountRecord
cursor := ""
for range maxPaginationPages {
page, err := d.queryUsers(ctx, cursor)
if err != nil {
return nil, err
}
for _, u := range page.Data {
active := u.DeactivatedAt == nil
record := AccountRecord{
Email: u.Email,
FullName: u.Name,
Role: u.AccessRole,
Active: &active,
MFAStatus: coredata.MFAStatusUnknown,
AuthMethod: coredata.AccessEntryAuthMethodUnknown,
AccountType: coredata.AccessEntryAccountTypeUser,
ExternalID: u.ID,
}
if u.LastLoggedInAt != nil {
t := time.UnixMilli(*u.LastLoggedInAt)
record.LastLogin = &t
}
if u.CreatedAt != nil {
t := time.UnixMilli(*u.CreatedAt)
record.CreatedAt = &t
}
records = append(records, record)
}
if !page.HasNext || page.Next == "" {
return records, nil
}
cursor = page.Next
}
return nil, fmt.Errorf("cannot list all lever accounts: %w", ErrPaginationLimitReached)
}
func (d *LeverDriver) queryUsers(ctx context.Context, cursor string) (*leverUsersPage, error) {
q := url.Values{}
q.Set("limit", "100")
if cursor != "" {
q.Set("offset", cursor)
}
endpoint := "https://api.lever.co/v1/users?" + q.Encode()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, fmt.Errorf("cannot create lever 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 lever users request: %w", err)
}
defer func() { _ = httpResp.Body.Close() }()
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
return nil, fmt.Errorf("cannot fetch lever users: unexpected status %d", httpResp.StatusCode)
}
var page leverUsersPage
if err := json.NewDecoder(httpResp.Body).Decode(&page); err != nil {
return nil, fmt.Errorf("cannot decode lever users response: %w", err)
}
return &page, nil
}

View File

@@ -0,0 +1,50 @@
// 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 TestLeverDriver(t *testing.T) {
t.Parallel()
rec := newRecorder(t, "testdata/lever", "LEVER_TOKEN")
client := newVCRClient(rec, bearerAuth(os.Getenv("LEVER_TOKEN")))
driver := NewLeverDriver(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.ExternalID)
assert.NotEmpty(t, r.FullName)
assert.NotEmpty(t, r.Role)
require.NotNil(t, r.Active)
assert.True(t, *r.Active)
require.NotNil(t, r.LastLogin)
// Deactivated users (deactivatedAt non-null) should surface as Active=false.
require.Len(t, records, 2)
require.NotNil(t, records[1].Active)
assert.False(t, *records[1].Active)
}

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 (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"time"
"go.probo.inc/probo/pkg/coredata"
)
// mondayGraphQLEndpoint is Monday.com's REST endpoint that accepts
// GraphQL queries via POST.
const mondayGraphQLEndpoint = "https://api.monday.com/v2"
// mondayUsersListQuery paginates Monday.com users by `page` (1-indexed).
// MFA is exposed only via SCIM Enterprise — leave MFAStatus=Unknown.
const mondayUsersListQuery = `query($p: Int!) { users(limit: 200, page: $p) { id email name enabled is_admin is_guest is_pending last_activity created_at title } }`
// MondayDriver fetches users from the Monday.com GraphQL API using a
// pre-authenticated HTTP client. Note: Monday.com's API historically
// accepts a bare token in the Authorization header (no "Bearer "
// prefix), but it also accepts the Bearer-prefixed form produced by
// Probo's RefreshableClient. If a real recording surfaces a 401, swap
// the wire transport for one that strips the "Bearer " prefix.
type MondayDriver struct {
httpClient *http.Client
}
var _ Driver = (*MondayDriver)(nil)
func NewMondayDriver(httpClient *http.Client) *MondayDriver {
return &MondayDriver{
httpClient: &http.Client{
Transport: &retryRoundTripper{
next: httpClient.Transport,
maxRetries: 3,
},
},
}
}
type mondayUser struct {
ID string `json:"id"`
Email string `json:"email"`
Name string `json:"name"`
Enabled bool `json:"enabled"`
IsAdmin bool `json:"is_admin"`
IsGuest bool `json:"is_guest"`
IsPending bool `json:"is_pending"`
LastActivity string `json:"last_activity"`
CreatedAt string `json:"created_at"`
Title string `json:"title"`
}
type mondayUsersResponse struct {
Data struct {
Users []mondayUser `json:"users"`
} `json:"data"`
Errors []struct {
Message string `json:"message"`
} `json:"errors"`
}
func (d *MondayDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error) {
var records []AccountRecord
page := 1
for range maxPaginationPages {
users, err := d.queryUsers(ctx, page)
if err != nil {
return nil, err
}
if len(users) == 0 {
return records, nil
}
for _, u := range users {
active := u.Enabled && !u.IsPending
record := AccountRecord{
Email: u.Email,
FullName: u.Name,
JobTitle: u.Title,
Active: &active,
IsAdmin: u.IsAdmin,
MFAStatus: coredata.MFAStatusUnknown,
AuthMethod: coredata.AccessEntryAuthMethodUnknown,
AccountType: coredata.AccessEntryAccountTypeUser,
ExternalID: u.ID,
}
if u.LastActivity != "" {
if t, err := time.Parse(time.RFC3339, u.LastActivity); err == nil {
record.LastLogin = &t
}
}
if u.CreatedAt != "" {
if t, err := time.Parse(time.RFC3339, u.CreatedAt); err == nil {
record.CreatedAt = &t
}
}
records = append(records, record)
}
page++
}
return nil, fmt.Errorf("cannot list all monday accounts: %w", ErrPaginationLimitReached)
}
func (d *MondayDriver) queryUsers(ctx context.Context, page int) ([]mondayUser, error) {
body := struct {
Query string `json:"query"`
Variables map[string]any `json:"variables"`
}{
Query: mondayUsersListQuery,
Variables: map[string]any{"p": page},
}
payload, err := json.Marshal(body)
if err != nil {
return nil, fmt.Errorf("cannot marshal monday users query: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, mondayGraphQLEndpoint, bytes.NewReader(payload))
if err != nil {
return nil, fmt.Errorf("cannot create monday 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 monday users request: %w", err)
}
defer func() { _ = httpResp.Body.Close() }()
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
return nil, fmt.Errorf("cannot fetch monday users: unexpected status %d", httpResp.StatusCode)
}
var resp mondayUsersResponse
if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil {
return nil, fmt.Errorf("cannot decode monday users response: %w", err)
}
if len(resp.Errors) > 0 {
return nil, fmt.Errorf("monday graphql error: %s", resp.Errors[0].Message)
}
return resp.Data.Users, nil
}

View File

@@ -0,0 +1,51 @@
// 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 TestMondayDriver(t *testing.T) {
t.Parallel()
rec := newRecorder(t, "testdata/monday", "MONDAY_TOKEN")
client := newVCRClient(rec, bearerAuth(os.Getenv("MONDAY_TOKEN")))
driver := NewMondayDriver(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.ExternalID)
assert.NotEmpty(t, r.FullName)
assert.True(t, r.IsAdmin)
assert.NotEmpty(t, r.JobTitle)
require.NotNil(t, r.Active)
assert.True(t, *r.Active)
require.NotNil(t, r.LastLogin)
// Pending users should surface as Active=false even when enabled.
require.Len(t, records, 2)
require.NotNil(t, records[1].Active)
assert.False(t, *records[1].Active)
}

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"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/rfc5988"
)
// NetlifyDriver fetches account members from the Netlify REST API
// using a pre-authenticated HTTP client (Bearer token). Pagination is
// driven by the standard RFC 5988 `Link` header with `rel="next"`.
//
// The Netlify member object exposes id / full_name / email / role only.
// There is no Active / MFA / last-login signal, so those fields are
// left at their zero defaults / nil / Unknown.
type NetlifyDriver struct {
httpClient *http.Client
accountSlug string
}
var _ Driver = (*NetlifyDriver)(nil)
func NewNetlifyDriver(httpClient *http.Client, accountSlug string) *NetlifyDriver {
return &NetlifyDriver{
httpClient: httpClient,
accountSlug: accountSlug,
}
}
type netlifyMember struct {
ID string `json:"id"`
FullName string `json:"full_name"`
Email string `json:"email"`
Role string `json:"role"`
}
func (d *NetlifyDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error) {
var records []AccountRecord
next := fmt.Sprintf(
"https://api.netlify.com/api/v1/%s/members?per_page=100",
d.accountSlug,
)
for range maxPaginationPages {
members, linkHeader, err := d.queryMembers(ctx, next)
if err != nil {
return nil, err
}
for _, m := range members {
record := AccountRecord{
Email: m.Email,
FullName: m.FullName,
Role: m.Role,
ExternalID: m.ID,
MFAStatus: coredata.MFAStatusUnknown,
AuthMethod: coredata.AccessEntryAuthMethodUnknown,
AccountType: coredata.AccessEntryAccountTypeUser,
}
records = append(records, record)
}
next = rfc5988.FindByRel(linkHeader, "next")
if next == "" {
return records, nil
}
}
return nil, fmt.Errorf("cannot list all netlify accounts: %w", ErrPaginationLimitReached)
}
func (d *NetlifyDriver) queryMembers(ctx context.Context, url string) ([]netlifyMember, string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, "", fmt.Errorf("cannot create netlify 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 netlify members request: %w", err)
}
defer func() { _ = httpResp.Body.Close() }()
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
return nil, "", fmt.Errorf("cannot fetch netlify members: unexpected status %d", httpResp.StatusCode)
}
var members []netlifyMember
if err := json.NewDecoder(httpResp.Body).Decode(&members); err != nil {
return nil, "", fmt.Errorf("cannot decode netlify members response: %w", err)
}
return members, httpResp.Header.Get("Link"), 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"
)
func TestNetlifyDriver(t *testing.T) {
t.Parallel()
rec := newRecorder(t, "testdata/netlify", "NETLIFY_TOKEN")
client := newVCRClient(rec, bearerAuth(os.Getenv("NETLIFY_TOKEN")))
accountSlug := os.Getenv("NETLIFY_ACCOUNT_SLUG")
if accountSlug == "" {
accountSlug = "acme"
}
driver := NewNetlifyDriver(client, accountSlug)
records, err := driver.ListAccounts(context.Background())
require.NoError(t, err)
require.Len(t, records, 2)
r := records[0]
assert.Equal(t, "member-1", r.ExternalID)
assert.Equal(t, "jane@example.com", r.Email)
assert.Equal(t, "Jane Doe", r.FullName)
assert.Equal(t, "Owner", r.Role)
}

View File

@@ -0,0 +1,150 @@
// 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"
)
// PagerDutyDriver fetches users from the PagerDuty REST API using a
// pre-authenticated HTTP client (Bearer token from the Scoped OAuth
// PKCE flow). Pagination is offset / limit based.
type PagerDutyDriver struct {
httpClient *http.Client
}
var _ Driver = (*PagerDutyDriver)(nil)
func NewPagerDutyDriver(httpClient *http.Client) *PagerDutyDriver {
return &PagerDutyDriver{
httpClient: &http.Client{
Transport: &retryRoundTripper{
next: httpClient.Transport,
maxRetries: 3,
},
},
}
}
type pagerdutyUser struct {
ID string `json:"id"`
Email string `json:"email"`
Name string `json:"name"`
Role string `json:"role"`
InvitationSent bool `json:"invitation_sent"`
CreatedAt string `json:"created_at"`
}
type pagerdutyUsersPage struct {
Users []pagerdutyUser `json:"users"`
More bool `json:"more"`
Limit int `json:"limit"`
}
func (d *PagerDutyDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error) {
var records []AccountRecord
const limit = 100
offset := 0
for range maxPaginationPages {
page, err := d.queryUsers(ctx, offset, limit)
if err != nil {
return nil, err
}
for _, u := range page.Users {
isAdmin := u.Role == "admin" || u.Role == "owner"
record := AccountRecord{
Email: u.Email,
FullName: u.Name,
Role: u.Role,
IsAdmin: isAdmin,
MFAStatus: coredata.MFAStatusUnknown,
AuthMethod: coredata.AccessEntryAuthMethodUnknown,
AccountType: coredata.AccessEntryAccountTypeUser,
ExternalID: u.ID,
}
// invitation_sent=true means the invitation is still pending,
// so the account is not yet active. Once accepted the field
// flips to false; we cannot tell active-vs-deactivated apart
// in that case and leave Active nil.
if u.InvitationSent {
active := false
record.Active = &active
}
if u.CreatedAt != "" {
if t, err := time.Parse(time.RFC3339, u.CreatedAt); err == nil {
record.CreatedAt = &t
}
}
records = append(records, record)
}
if !page.More {
return records, nil
}
pageSize := page.Limit
if pageSize <= 0 {
pageSize = limit
}
offset += pageSize
}
return nil, fmt.Errorf("cannot list all pagerduty accounts: %w", ErrPaginationLimitReached)
}
func (d *PagerDutyDriver) queryUsers(ctx context.Context, offset, limit int) (*pagerdutyUsersPage, error) {
q := url.Values{}
q.Set("limit", strconv.Itoa(limit))
q.Set("offset", strconv.Itoa(offset))
endpoint := "https://api.pagerduty.com/users?" + q.Encode()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, fmt.Errorf("cannot create pagerduty users request: %w", err)
}
req.Header.Set("Accept", "application/vnd.pagerduty+json;version=2")
httpResp, err := d.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("cannot execute pagerduty users request: %w", err)
}
defer func() { _ = httpResp.Body.Close() }()
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
return nil, fmt.Errorf("cannot fetch pagerduty users: unexpected status %d", httpResp.StatusCode)
}
var page pagerdutyUsersPage
if err := json.NewDecoder(httpResp.Body).Decode(&page); err != nil {
return nil, fmt.Errorf("cannot decode pagerduty users response: %w", err)
}
return &page, nil
}

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 TestPagerDutyDriver(t *testing.T) {
t.Parallel()
rec := newRecorder(t, "testdata/pagerduty", "PAGERDUTY_TOKEN")
client := newVCRClient(rec, bearerAuth(os.Getenv("PAGERDUTY_TOKEN")))
driver := NewPagerDutyDriver(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.ExternalID)
assert.NotEmpty(t, r.FullName)
assert.NotEmpty(t, r.Role)
assert.True(t, r.IsAdmin)
// Pending invites should surface as Active=false.
require.Len(t, records, 2)
require.NotNil(t, records[1].Active)
assert.False(t, *records[1].Active)
}

View File

@@ -0,0 +1,137 @@
// 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"
"time"
"go.probo.inc/probo/pkg/coredata"
)
// RampDriver fetches users from the Ramp Developer API using a
// pre-authenticated HTTP client (Bearer token). Ramp grants are scoped
// to a single business — there is no per-business picker, so this is a
// Pattern 1 driver. Pagination is via the absolute URL exposed in
// `page.next` on the response body.
type RampDriver struct {
httpClient *http.Client
}
var _ Driver = (*RampDriver)(nil)
func NewRampDriver(httpClient *http.Client) *RampDriver {
return &RampDriver{
httpClient: &http.Client{
Transport: &retryRoundTripper{
next: httpClient.Transport,
maxRetries: 3,
},
},
}
}
type rampUser struct {
ID string `json:"id"`
Email string `json:"email"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
Role string `json:"role"`
Status string `json:"status"`
LastLoginAt string `json:"last_login_at"`
IsManager bool `json:"is_manager"`
}
type rampUsersPage struct {
Data []rampUser `json:"data"`
Page struct {
Next string `json:"next"`
} `json:"page"`
}
func (d *RampDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error) {
var records []AccountRecord
next := "https://api.ramp.com/developer/v1/users?page_size=100"
for range maxPaginationPages {
page, err := d.queryUsers(ctx, next)
if err != nil {
return nil, err
}
for _, u := range page.Data {
fullName := strings.TrimSpace(u.FirstName + " " + u.LastName)
active := u.Status == "USER_ACTIVE"
record := AccountRecord{
Email: u.Email,
FullName: fullName,
Role: u.Role,
Active: &active,
IsAdmin: u.IsManager,
ExternalID: u.ID,
MFAStatus: coredata.MFAStatusUnknown,
AuthMethod: coredata.AccessEntryAuthMethodUnknown,
AccountType: coredata.AccessEntryAccountTypeUser,
}
if u.LastLoginAt != "" {
if t, err := time.Parse(time.RFC3339, u.LastLoginAt); err == nil {
record.LastLogin = &t
}
}
records = append(records, record)
}
if page.Page.Next == "" {
return records, nil
}
next = page.Page.Next
}
return nil, fmt.Errorf("cannot list all ramp accounts: %w", ErrPaginationLimitReached)
}
func (d *RampDriver) queryUsers(ctx context.Context, url string) (*rampUsersPage, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("cannot create ramp 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 ramp users request: %w", err)
}
defer func() { _ = httpResp.Body.Close() }()
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
return nil, fmt.Errorf("cannot fetch ramp users: unexpected status %d", httpResp.StatusCode)
}
var page rampUsersPage
if err := json.NewDecoder(httpResp.Body).Decode(&page); err != nil {
return nil, fmt.Errorf("cannot decode ramp users response: %w", err)
}
return &page, nil
}

View File

@@ -0,0 +1,50 @@
// 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 TestRampDriver(t *testing.T) {
t.Parallel()
rec := newRecorder(t, "testdata/ramp", "RAMP_TOKEN")
client := newVCRClient(rec, bearerAuth(os.Getenv("RAMP_TOKEN")))
driver := NewRampDriver(client)
records, err := driver.ListAccounts(context.Background())
require.NoError(t, err)
require.Len(t, records, 2)
r := records[0]
assert.Equal(t, "user-1", r.ExternalID)
assert.Equal(t, "jane@example.com", r.Email)
assert.Equal(t, "Jane Doe", r.FullName)
assert.Equal(t, "BUSINESS_ADMIN", r.Role)
require.NotNil(t, r.Active)
assert.True(t, *r.Active)
assert.True(t, r.IsAdmin)
require.NotNil(t, r.LastLogin)
// Suspended record should be Active=false.
require.NotNil(t, records[1].Active)
assert.False(t, *records[1].Active)
}

View File

@@ -0,0 +1,144 @@
// 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"
)
// SnykDriver fetches organization memberships from the Snyk REST API
// using a pre-authenticated HTTP client (Bearer token from the Snyk
// Apps OAuth PKCE flow). Pagination is via the `links.next` field on
// the response body (a relative URL fragment under api.snyk.io).
//
// Note: Snyk uses a single-use rotating refresh token (~180d TTL).
// Persistence of the rotated refresh token is handled by the existing
// callers — see pkg/accessreview/access_source_service.go:336-347 for
// the campaign-fetch path and pkg/accessreview/source_name_worker.go:121-128
// for the source-name path. Both run inside a transaction so concurrent
// runs serialise per-row.
type SnykDriver struct {
httpClient *http.Client
orgID string
}
var _ Driver = (*SnykDriver)(nil)
func NewSnykDriver(httpClient *http.Client, orgID string) *SnykDriver {
return &SnykDriver{
httpClient: &http.Client{
Transport: &retryRoundTripper{
next: httpClient.Transport,
maxRetries: 3,
},
},
orgID: orgID,
}
}
type snykMembership struct {
ID string `json:"id"`
Attributes struct {
User struct {
Email string `json:"email"`
Name string `json:"name"`
} `json:"user"`
Role struct {
Name string `json:"name"`
} `json:"role"`
} `json:"attributes"`
}
type snykMembershipsPage struct {
Data []snykMembership `json:"data"`
Links struct {
Next string `json:"next"`
} `json:"links"`
}
func (d *SnykDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error) {
var records []AccountRecord
next := fmt.Sprintf(
"https://api.snyk.io/rest/orgs/%s/memberships?version=2024-10-15&limit=100",
d.orgID,
)
for range maxPaginationPages {
page, err := d.queryMemberships(ctx, next)
if err != nil {
return nil, err
}
for _, m := range page.Data {
record := AccountRecord{
Email: m.Attributes.User.Email,
FullName: m.Attributes.User.Name,
Role: m.Attributes.Role.Name,
ExternalID: m.ID,
MFAStatus: coredata.MFAStatusUnknown,
AuthMethod: coredata.AccessEntryAuthMethodUnknown,
AccountType: coredata.AccessEntryAccountTypeUser,
}
records = append(records, record)
}
if page.Links.Next == "" {
return records, nil
}
// Snyk surfaces `links.next` as either a path-only fragment
// (e.g. "/rest/orgs/<id>/memberships?...&starting_after=...")
// or an absolute URL. Normalise to absolute.
if strings.HasPrefix(page.Links.Next, "http://") || strings.HasPrefix(page.Links.Next, "https://") {
next = page.Links.Next
} else {
next = "https://api.snyk.io" + page.Links.Next
}
}
return nil, fmt.Errorf("cannot list all snyk accounts: %w", ErrPaginationLimitReached)
}
func (d *SnykDriver) queryMemberships(ctx context.Context, url string) (*snykMembershipsPage, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("cannot create snyk memberships request: %w", err)
}
req.Header.Set("Accept", "application/vnd.api+json")
httpResp, err := d.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("cannot execute snyk memberships request: %w", err)
}
defer func() { _ = httpResp.Body.Close() }()
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
return nil, fmt.Errorf("cannot fetch snyk memberships: unexpected status %d", httpResp.StatusCode)
}
var page snykMembershipsPage
if err := json.NewDecoder(httpResp.Body).Decode(&page); err != nil {
return nil, fmt.Errorf("cannot decode snyk memberships response: %w", err)
}
return &page, 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"
)
func TestSnykDriver(t *testing.T) {
t.Parallel()
rec := newRecorder(t, "testdata/snyk", "SNYK_TOKEN")
client := newVCRClient(rec, bearerAuth(os.Getenv("SNYK_TOKEN")))
orgID := os.Getenv("SNYK_ORG_ID")
if orgID == "" {
orgID = "org-1234"
}
driver := NewSnykDriver(client, orgID)
records, err := driver.ListAccounts(context.Background())
require.NoError(t, err)
require.Len(t, records, 2)
r := records[0]
assert.Equal(t, "membership-1", r.ExternalID)
assert.Equal(t, "jane@example.com", r.Email)
assert.Equal(t, "Jane Doe", r.FullName)
assert.Equal(t, "Admin", r.Role)
}

View File

@@ -0,0 +1,35 @@
---
version: 2
interactions:
- id: 0
request:
proto: HTTP/1.1
proto_major: 1
proto_minor: 1
content_length: 0
host: app.asana.com
form:
limit:
- "100"
opt_fields:
- "email,name"
headers:
Accept:
- application/json
url: https://app.asana.com/api/1.0/workspaces/9999999/users?opt_fields=email,name&limit=100
method: GET
response:
proto: HTTP/2.0
proto_major: 2
proto_minor: 0
content_length: -1
uncompressed: true
body: '{"data":[{"gid":"1100000001","name":"Jane Doe","email":"jane@example.com"},{"gid":"1100000002","name":"Bob Smith","email":""}],"next_page":null}'
headers:
Content-Type:
- application/json
Date:
- Thu, 01 May 2026 12:00:00 GMT
status: 200 OK
code: 200
duration: 100ms

View File

@@ -0,0 +1,35 @@
---
version: 2
interactions:
- id: 0
request:
proto: HTTP/1.1
proto_major: 1
proto_minor: 1
content_length: 0
host: api.bitbucket.org
form:
fields:
- "+values.user.email"
pagelen:
- "100"
headers:
Accept:
- application/json
url: https://api.bitbucket.org/2.0/workspaces/acme/members?fields=%2Bvalues.user.email&pagelen=100
method: GET
response:
proto: HTTP/2.0
proto_major: 2
proto_minor: 0
content_length: -1
uncompressed: true
body: '{"values":[{"user":{"account_id":"557058:abc-123","display_name":"Jane Doe","nickname":"jane","email":"jane@example.com"}},{"user":{"account_id":"557058:def-456","display_name":"Bob Smith","nickname":"bsmith","email":""}}]}'
headers:
Content-Type:
- application/json
Date:
- Thu, 01 May 2026 12:00:00 GMT
status: 200 OK
code: 200
duration: 100ms

View File

@@ -0,0 +1,30 @@
---
version: 2
interactions:
- id: 0
request:
proto: HTTP/1.1
proto_major: 1
proto_minor: 1
content_length: 0
host: api.clickup.com
headers:
Accept:
- application/json
url: https://api.clickup.com/api/v2/team/9999999
method: GET
response:
proto: HTTP/2.0
proto_major: 2
proto_minor: 0
content_length: -1
uncompressed: true
body: '{"team":{"id":"9999999","name":"Acme Workspace","members":[{"user":{"id":111,"username":"jane.doe","email":"jane@example.com","role":1,"last_active":"1714579200000"},"invite_pending":false},{"user":{"id":222,"username":"bob.smith","email":"bob@example.com","role":3,"last_active":""},"invite_pending":true}]}}'
headers:
Content-Type:
- application/json
Date:
- Thu, 01 May 2026 12:00:00 GMT
status: 200 OK
code: 200
duration: 100ms

View File

@@ -0,0 +1,67 @@
---
version: 2
interactions:
- id: 0
request:
proto: HTTP/1.1
proto_major: 1
proto_minor: 1
content_length: 0
host: api.letsdeel.com
form:
limit:
- "100"
offset:
- "0"
headers:
Accept:
- application/json
url: https://api.letsdeel.com/rest/v2/people?limit=100&offset=0
method: GET
response:
proto: HTTP/2.0
proto_major: 2
proto_minor: 0
content_length: -1
uncompressed: true
body: '{"data":[{"id":"d_jane","email":"jane@example.com","first_name":"Jane","last_name":"Doe","job_title":"Engineering Lead","hiring_status":"active","start_date":"2024-06-01","end_date":""},{"id":"d_bob","email":"bob@example.com","first_name":"Bob","last_name":"Smith","job_title":"Designer","hiring_status":"inactive","start_date":"2023-01-15","end_date":"2025-09-30"}]}'
headers:
Content-Type:
- application/json
Date:
- Thu, 01 May 2026 12:00:00 GMT
status: 200 OK
code: 200
duration: 100ms
- id: 1
request:
proto: HTTP/1.1
proto_major: 1
proto_minor: 1
content_length: 0
host: api.letsdeel.com
form:
limit:
- "100"
offset:
- "100"
headers:
Accept:
- application/json
url: https://api.letsdeel.com/rest/v2/people?limit=100&offset=100
method: GET
response:
proto: HTTP/2.0
proto_major: 2
proto_minor: 0
content_length: -1
uncompressed: true
body: '{"data":[]}'
headers:
Content-Type:
- application/json
Date:
- Thu, 01 May 2026 12:00:00 GMT
status: 200 OK
code: 200
duration: 100ms

View File

@@ -0,0 +1,33 @@
---
version: 2
interactions:
- id: 0
request:
proto: HTTP/1.1
proto_major: 1
proto_minor: 1
content_length: 0
host: gitlab.com
form:
per_page:
- "100"
headers:
Accept:
- application/json
url: https://gitlab.com/api/v4/groups/12345/members/all?per_page=100
method: GET
response:
proto: HTTP/2.0
proto_major: 2
proto_minor: 0
content_length: -1
uncompressed: true
body: '[{"id":1000001,"username":"jane.doe","name":"Jane Doe","email":"jane@example.com","state":"active","access_level":50},{"id":1000002,"username":"bob.smith","name":"Bob Smith","email":"bob@example.com","state":"active","access_level":30},{"id":1000003,"username":"alice","name":"Alice","email":null,"state":"blocked","access_level":10}]'
headers:
Content-Type:
- application/json
Date:
- Thu, 01 May 2026 12:00:00 GMT
status: 200 OK
code: 200
duration: 100ms

View File

@@ -0,0 +1,30 @@
---
version: 2
interactions:
- id: 0
request:
proto: HTTP/1.1
proto_major: 1
proto_minor: 1
content_length: 0
host: api.heroku.com
headers:
Accept:
- application/vnd.heroku+json; version=3
url: https://api.heroku.com/teams/acme/members
method: GET
response:
proto: HTTP/2.0
proto_major: 2
proto_minor: 0
content_length: -1
uncompressed: true
body: '[{"id":"00000000-0000-0000-0000-000000000001","email":"jane@example.com","role":"admin","two_factor_authentication":true,"federated":false,"created_at":"2024-06-01T12:00:00Z","user":{"id":"u-1","email":"jane@example.com","name":"Jane Doe"}},{"id":"00000000-0000-0000-0000-000000000002","email":"bob@example.com","role":"member","two_factor_authentication":false,"federated":false,"created_at":"2025-01-15T09:00:00Z","user":{"id":"u-2","email":"bob@example.com","name":"Bob Smith"}}]'
headers:
Content-Type:
- application/json
Date:
- Thu, 01 May 2026 12:00:00 GMT
status: 200 OK
code: 200
duration: 100ms

View File

@@ -0,0 +1,33 @@
---
version: 2
interactions:
- id: 0
request:
proto: HTTP/1.1
proto_major: 1
proto_minor: 1
content_length: 0
host: api.lever.co
form:
limit:
- "100"
headers:
Accept:
- application/json
url: https://api.lever.co/v1/users?limit=100
method: GET
response:
proto: HTTP/2.0
proto_major: 2
proto_minor: 0
content_length: -1
uncompressed: true
body: '{"data":[{"id":"l_jane","email":"jane@example.com","name":"Jane Doe","accessRole":"super admin","deactivatedAt":null,"lastLoggedInAt":1745000000000,"createdAt":1717200000000},{"id":"l_bob","email":"bob@example.com","name":"Bob Smith","accessRole":"admin","deactivatedAt":1735000000000,"lastLoggedInAt":null,"createdAt":1717200000000}],"hasNext":false,"next":""}'
headers:
Content-Type:
- application/json
Date:
- Thu, 01 May 2026 12:00:00 GMT
status: 200 OK
code: 200
duration: 100ms

View File

@@ -0,0 +1,63 @@
---
version: 2
interactions:
- id: 0
request:
proto: HTTP/1.1
proto_major: 1
proto_minor: 1
content_length: 165
host: api.monday.com
body: '{"query":"query($p: Int!) { users(limit: 200, page: $p) { id email name enabled is_admin is_guest is_pending last_activity created_at title } }","variables":{"p":1}}'
headers:
Accept:
- application/json
Content-Type:
- application/json
url: https://api.monday.com/v2
method: POST
response:
proto: HTTP/2.0
proto_major: 2
proto_minor: 0
content_length: -1
uncompressed: true
body: '{"data":{"users":[{"id":"1001","email":"jane@example.com","name":"Jane Doe","enabled":true,"is_admin":true,"is_guest":false,"is_pending":false,"last_activity":"2026-04-30T15:00:00Z","created_at":"2024-06-01T12:00:00Z","title":"Engineering Lead"},{"id":"1002","email":"bob@example.com","name":"Bob Smith","enabled":true,"is_admin":false,"is_guest":false,"is_pending":true,"last_activity":"","created_at":"2025-03-10T09:30:00Z","title":""}]}}'
headers:
Content-Type:
- application/json
Date:
- Thu, 01 May 2026 12:00:00 GMT
status: 200 OK
code: 200
duration: 100ms
- id: 1
request:
proto: HTTP/1.1
proto_major: 1
proto_minor: 1
content_length: 165
host: api.monday.com
body: '{"query":"query($p: Int!) { users(limit: 200, page: $p) { id email name enabled is_admin is_guest is_pending last_activity created_at title } }","variables":{"p":2}}'
headers:
Accept:
- application/json
Content-Type:
- application/json
url: https://api.monday.com/v2
method: POST
response:
proto: HTTP/2.0
proto_major: 2
proto_minor: 0
content_length: -1
uncompressed: true
body: '{"data":{"users":[]}}'
headers:
Content-Type:
- application/json
Date:
- Thu, 01 May 2026 12:00:00 GMT
status: 200 OK
code: 200
duration: 100ms

View File

@@ -0,0 +1,33 @@
---
version: 2
interactions:
- id: 0
request:
proto: HTTP/1.1
proto_major: 1
proto_minor: 1
content_length: 0
host: api.netlify.com
form:
per_page:
- "100"
headers:
Accept:
- application/json
url: https://api.netlify.com/api/v1/acme/members?per_page=100
method: GET
response:
proto: HTTP/2.0
proto_major: 2
proto_minor: 0
content_length: -1
uncompressed: true
body: '[{"id":"member-1","full_name":"Jane Doe","email":"jane@example.com","role":"Owner"},{"id":"member-2","full_name":"Bob Smith","email":"bob@example.com","role":"Collaborator"}]'
headers:
Content-Type:
- application/json
Date:
- Thu, 01 May 2026 12:00:00 GMT
status: 200 OK
code: 200
duration: 100ms

View File

@@ -0,0 +1,35 @@
---
version: 2
interactions:
- id: 0
request:
proto: HTTP/1.1
proto_major: 1
proto_minor: 1
content_length: 0
host: api.pagerduty.com
form:
limit:
- "100"
offset:
- "0"
headers:
Accept:
- application/vnd.pagerduty+json;version=2
url: https://api.pagerduty.com/users?limit=100&offset=0
method: GET
response:
proto: HTTP/2.0
proto_major: 2
proto_minor: 0
content_length: -1
uncompressed: true
body: '{"users":[{"id":"PXAAAAA","email":"jane@example.com","name":"Jane Doe","role":"admin","invitation_sent":false,"created_at":"2024-06-01T12:00:00Z"},{"id":"PXBBBBB","email":"bob@example.com","name":"Bob Smith","role":"user","invitation_sent":true,"created_at":"2025-04-10T09:30:00Z"}],"more":false,"limit":100,"offset":0,"total":null}'
headers:
Content-Type:
- application/json
Date:
- Thu, 01 May 2026 12:00:00 GMT
status: 200 OK
code: 200
duration: 100ms

View File

@@ -0,0 +1,33 @@
---
version: 2
interactions:
- id: 0
request:
proto: HTTP/1.1
proto_major: 1
proto_minor: 1
content_length: 0
host: api.ramp.com
form:
page_size:
- "100"
headers:
Accept:
- application/json
url: https://api.ramp.com/developer/v1/users?page_size=100
method: GET
response:
proto: HTTP/2.0
proto_major: 2
proto_minor: 0
content_length: -1
uncompressed: true
body: '{"data":[{"id":"user-1","email":"jane@example.com","first_name":"Jane","last_name":"Doe","role":"BUSINESS_ADMIN","status":"USER_ACTIVE","last_login_at":"2026-04-15T10:00:00Z","is_manager":true},{"id":"user-2","email":"bob@example.com","first_name":"Bob","last_name":"Smith","role":"BUSINESS_USER","status":"USER_SUSPENDED","last_login_at":"","is_manager":false}],"page":{"next":""}}'
headers:
Content-Type:
- application/json
Date:
- Thu, 01 May 2026 12:00:00 GMT
status: 200 OK
code: 200
duration: 100ms

View File

@@ -0,0 +1,35 @@
---
version: 2
interactions:
- id: 0
request:
proto: HTTP/1.1
proto_major: 1
proto_minor: 1
content_length: 0
host: api.snyk.io
form:
limit:
- "100"
version:
- "2024-10-15"
headers:
Accept:
- application/vnd.api+json
url: https://api.snyk.io/rest/orgs/org-1234/memberships?version=2024-10-15&limit=100
method: GET
response:
proto: HTTP/2.0
proto_major: 2
proto_minor: 0
content_length: -1
uncompressed: true
body: '{"data":[{"id":"membership-1","type":"org_membership","attributes":{"user":{"id":"user-1","email":"jane@example.com","name":"Jane Doe"},"role":{"id":"role-1","name":"Admin"}}},{"id":"membership-2","type":"org_membership","attributes":{"user":{"id":"user-2","email":"bob@example.com","name":"Bob Smith"},"role":{"id":"role-2","name":"Collaborator"}}}],"links":{"self":"/rest/orgs/org-1234/memberships?version=2024-10-15&limit=100"}}'
headers:
Content-Type:
- application/vnd.api+json
Date:
- Thu, 01 May 2026 12:00:00 GMT
status: 200 OK
code: 200
duration: 100ms

View File

@@ -0,0 +1,33 @@
---
version: 2
interactions:
- id: 0
request:
proto: HTTP/1.1
proto_major: 1
proto_minor: 1
content_length: 0
host: api.vercel.com
form:
limit:
- "100"
headers:
Accept:
- application/json
url: https://api.vercel.com/v3/teams/team_acme/members?limit=100
method: GET
response:
proto: HTTP/2.0
proto_major: 2
proto_minor: 0
content_length: -1
uncompressed: true
body: '{"members":[{"uid":"u_jane","email":"jane@example.com","username":"jane","name":"Jane Doe","role":"OWNER","confirmed":true,"isEnterpriseManaged":false,"joinedFrom":{"origin":"manual"}},{"uid":"u_bob","email":"bob@example.com","username":"bob","name":"Bob Smith","role":"MEMBER","confirmed":false,"isEnterpriseManaged":false,"joinedFrom":{"origin":"invite"}}],"pagination":{"next":""}}'
headers:
Content-Type:
- application/json
Date:
- Thu, 01 May 2026 12:00:00 GMT
status: 200 OK
code: 200
duration: 100ms

View File

@@ -0,0 +1,147 @@
// 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"
)
// VercelDriver fetches team members from the Vercel REST API using a
// pre-authenticated HTTP client (Bearer token). The TeamID is captured
// during the OAuth callback (Pattern 2-auto). Pagination is via the
// `pagination.next` cursor on the response body, replayed as the
// `?until=<cursor>` query parameter on the next request.
//
// Notes on data quality:
// - When `isEnterpriseManaged` is true on a member, the IdP is the
// source of truth for MFA — the v3 members endpoint does not surface
// MFA status, so MFAStatus is always Unknown.
// - The driver does not wrap the transport with retryRoundTripper:
// Vercel's documented rate-limit contract is loose enough that the
// extra retry layer is not warranted in v1.
type VercelDriver struct {
httpClient *http.Client
teamID string
}
var _ Driver = (*VercelDriver)(nil)
func NewVercelDriver(httpClient *http.Client, teamID string) *VercelDriver {
return &VercelDriver{
httpClient: httpClient,
teamID: teamID,
}
}
type vercelMember struct {
UID string `json:"uid"`
Email string `json:"email"`
Username string `json:"username"`
Name string `json:"name"`
Role string `json:"role"`
Confirmed bool `json:"confirmed"`
IsEnterpriseManaged bool `json:"isEnterpriseManaged"`
JoinedFrom struct {
Origin string `json:"origin"`
} `json:"joinedFrom"`
}
type vercelMembersPage struct {
Members []vercelMember `json:"members"`
Pagination struct {
Next string `json:"next"`
} `json:"pagination"`
}
func (d *VercelDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error) {
var records []AccountRecord
base := fmt.Sprintf("https://api.vercel.com/v3/teams/%s/members", url.PathEscape(d.teamID))
cursor := ""
for range maxPaginationPages {
page, err := d.queryMembers(ctx, base, cursor)
if err != nil {
return nil, err
}
for _, m := range page.Members {
fullName := m.Name
if fullName == "" {
fullName = m.Username
}
confirmed := m.Confirmed
record := AccountRecord{
Email: m.Email,
FullName: fullName,
Role: m.Role,
Active: &confirmed,
IsAdmin: m.Role == "OWNER" || m.Role == "owner",
MFAStatus: coredata.MFAStatusUnknown,
AuthMethod: coredata.AccessEntryAuthMethodUnknown,
AccountType: coredata.AccessEntryAccountTypeUser,
ExternalID: m.UID,
}
records = append(records, record)
}
if page.Pagination.Next == "" {
return records, nil
}
cursor = page.Pagination.Next
}
return nil, fmt.Errorf("cannot list all vercel accounts: %w", ErrPaginationLimitReached)
}
func (d *VercelDriver) queryMembers(ctx context.Context, base, cursor string) (*vercelMembersPage, error) {
q := url.Values{}
q.Set("limit", "100")
if cursor != "" {
q.Set("until", cursor)
}
endpoint := base + "?" + q.Encode()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, fmt.Errorf("cannot create vercel 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 vercel members request: %w", err)
}
defer func() { _ = httpResp.Body.Close() }()
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
return nil, fmt.Errorf("cannot fetch vercel members: unexpected status %d", httpResp.StatusCode)
}
var page vercelMembersPage
if err := json.NewDecoder(httpResp.Body).Decode(&page); err != nil {
return nil, fmt.Errorf("cannot decode vercel members response: %w", err)
}
return &page, nil
}

View File

@@ -0,0 +1,55 @@
// 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 TestVercelDriver(t *testing.T) {
t.Parallel()
rec := newRecorder(t, "testdata/vercel", "VERCEL_TOKEN")
client := newVCRClient(rec, bearerAuth(os.Getenv("VERCEL_TOKEN")))
teamID := os.Getenv("VERCEL_TEAM_ID")
if teamID == "" {
teamID = "team_acme"
}
driver := NewVercelDriver(client, teamID)
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.ExternalID)
assert.NotEmpty(t, r.FullName)
assert.NotEmpty(t, r.Role)
assert.True(t, r.IsAdmin)
require.NotNil(t, r.Active)
assert.True(t, *r.Active)
// Unconfirmed members must surface as Active=false.
require.Len(t, records, 2)
require.NotNil(t, records[1].Active)
assert.False(t, *records[1].Active)
}

View File

@@ -374,6 +374,99 @@ func (e *ReviewEngine) resolveDriver(
return drivers.NewResendDriver(httpClient), nil return drivers.NewResendDriver(httpClient), nil
case coredata.ConnectorProviderMicrosoft365: case coredata.ConnectorProviderMicrosoft365:
return drivers.NewMicrosoft365Driver(httpClient), nil return drivers.NewMicrosoft365Driver(httpClient), nil
case coredata.ConnectorProviderGitLab:
gitlabSettings, err := dbConnector.GitLabSettings()
if err != nil {
return nil, fmt.Errorf("cannot read gitlab connector settings: %w", err)
}
if gitlabSettings.GroupID == "" {
return nil, fmt.Errorf("gitlab connector requires group_id in settings")
}
return drivers.NewGitLabDriver(httpClient, gitlabSettings.GroupID), nil
case coredata.ConnectorProviderBitbucket:
bitbucketSettings, err := dbConnector.BitbucketSettings()
if err != nil {
return nil, fmt.Errorf("cannot read bitbucket connector settings: %w", err)
}
if bitbucketSettings.Workspace == "" {
return nil, fmt.Errorf("bitbucket connector requires workspace in settings")
}
return drivers.NewBitbucketDriver(httpClient, bitbucketSettings.Workspace), nil
case coredata.ConnectorProviderHeroku:
herokuSettings, err := dbConnector.HerokuSettings()
if err != nil {
return nil, fmt.Errorf("cannot read heroku connector settings: %w", err)
}
if herokuSettings.TeamID == "" {
return nil, fmt.Errorf("heroku connector requires team_id in settings")
}
return drivers.NewHerokuDriver(httpClient, herokuSettings.TeamID), nil
case coredata.ConnectorProviderPagerDuty:
// Subdomain is required for the name resolver only; the driver
// itself does not need it because PagerDuty's REST API uses the
// regional api.pagerduty.com host. We still surface a clear
// error if the OAuth callback failed to capture the subdomain.
pdSettings, err := dbConnector.PagerDutySettings()
if err != nil {
return nil, fmt.Errorf("cannot read pagerduty connector settings: %w", err)
}
if pdSettings.Subdomain == "" {
return nil, fmt.Errorf("pagerduty connector requires subdomain in settings")
}
return drivers.NewPagerDutyDriver(httpClient), nil
case coredata.ConnectorProviderAsana:
asanaSettings, err := dbConnector.AsanaSettings()
if err != nil {
return nil, fmt.Errorf("cannot read asana connector settings: %w", err)
}
if asanaSettings.WorkspaceGID == "" {
return nil, fmt.Errorf("asana connector requires workspace_gid in settings")
}
return drivers.NewAsanaDriver(httpClient, asanaSettings.WorkspaceGID), nil
case coredata.ConnectorProviderSnyk:
snykSettings, err := dbConnector.SnykSettings()
if err != nil {
return nil, fmt.Errorf("cannot read snyk connector settings: %w", err)
}
if snykSettings.OrgID == "" {
return nil, fmt.Errorf("snyk connector requires org_id in settings")
}
return drivers.NewSnykDriver(httpClient, snykSettings.OrgID), nil
case coredata.ConnectorProviderNetlify:
netlifySettings, err := dbConnector.NetlifySettings()
if err != nil {
return nil, fmt.Errorf("cannot read netlify connector settings: %w", err)
}
if netlifySettings.AccountSlug == "" {
return nil, fmt.Errorf("netlify connector requires account_slug in settings")
}
return drivers.NewNetlifyDriver(httpClient, netlifySettings.AccountSlug), nil
case coredata.ConnectorProviderRamp:
return drivers.NewRampDriver(httpClient), nil
case coredata.ConnectorProviderClickUp:
clickupSettings, err := dbConnector.ClickUpSettings()
if err != nil {
return nil, fmt.Errorf("cannot read clickup connector settings: %w", err)
}
if clickupSettings.TeamID == "" {
return nil, fmt.Errorf("clickup connector requires team_id in settings")
}
return drivers.NewClickUpDriver(httpClient, clickupSettings.TeamID), nil
case coredata.ConnectorProviderVercel:
vercelSettings, err := dbConnector.VercelSettings()
if err != nil {
return nil, fmt.Errorf("cannot read vercel connector settings: %w", err)
}
if vercelSettings.TeamID == "" {
return nil, fmt.Errorf("vercel connector requires team_id in settings")
}
return drivers.NewVercelDriver(httpClient, vercelSettings.TeamID), nil
case coredata.ConnectorProviderMonday:
return drivers.NewMondayDriver(httpClient), nil
case coredata.ConnectorProviderLever:
return drivers.NewLeverDriver(httpClient), nil
case coredata.ConnectorProviderDeel:
return drivers.NewDeelDriver(httpClient), nil
default: default:
return nil, fmt.Errorf("unsupported connector provider %q for access source driver", dbConnector.Provider) return nil, fmt.Errorf("unsupported connector provider %q for access source driver", dbConnector.Provider)
} }

View File

@@ -283,6 +283,77 @@ func (h *sourceNameHandler) buildResolver(
return drivers.NewResendNameResolver() return drivers.NewResendNameResolver()
case coredata.ConnectorProviderMicrosoft365: case coredata.ConnectorProviderMicrosoft365:
return drivers.NewMicrosoft365NameResolver(httpClient) return drivers.NewMicrosoft365NameResolver(httpClient)
case coredata.ConnectorProviderGitLab:
gitlabSettings, err := dbConnector.GitLabSettings()
if err != nil {
h.logger.Error("cannot read gitlab connector settings", log.Error(err))
return nil
}
return drivers.NewGitLabNameResolver(httpClient, gitlabSettings.GroupID)
case coredata.ConnectorProviderBitbucket:
bitbucketSettings, err := dbConnector.BitbucketSettings()
if err != nil {
h.logger.Error("cannot read bitbucket connector settings", log.Error(err))
return nil
}
return drivers.NewBitbucketNameResolver(httpClient, bitbucketSettings.Workspace)
case coredata.ConnectorProviderHeroku:
herokuSettings, err := dbConnector.HerokuSettings()
if err != nil {
h.logger.Error("cannot read heroku connector settings", log.Error(err))
return nil
}
return drivers.NewHerokuNameResolver(httpClient, herokuSettings.TeamID)
case coredata.ConnectorProviderPagerDuty:
pdSettings, err := dbConnector.PagerDutySettings()
if err != nil {
h.logger.Error("cannot read pagerduty connector settings", log.Error(err))
return nil
}
return drivers.NewPagerDutyNameResolver(pdSettings.Subdomain)
case coredata.ConnectorProviderAsana:
asanaSettings, err := dbConnector.AsanaSettings()
if err != nil {
h.logger.Error("cannot read asana connector settings", log.Error(err))
return nil
}
return drivers.NewAsanaNameResolver(httpClient, asanaSettings.WorkspaceGID)
case coredata.ConnectorProviderSnyk:
snykSettings, err := dbConnector.SnykSettings()
if err != nil {
h.logger.Error("cannot read snyk connector settings", log.Error(err))
return nil
}
return drivers.NewSnykNameResolver(httpClient, snykSettings.OrgID)
case coredata.ConnectorProviderNetlify:
netlifySettings, err := dbConnector.NetlifySettings()
if err != nil {
h.logger.Error("cannot read netlify connector settings", log.Error(err))
return nil
}
return drivers.NewNetlifyNameResolver(httpClient, netlifySettings.AccountSlug)
case coredata.ConnectorProviderRamp:
return drivers.NewRampNameResolver(httpClient)
case coredata.ConnectorProviderClickUp:
clickupSettings, err := dbConnector.ClickUpSettings()
if err != nil {
h.logger.Error("cannot read clickup connector settings", log.Error(err))
return nil
}
return drivers.NewClickUpNameResolver(httpClient, clickupSettings.TeamID)
case coredata.ConnectorProviderVercel:
vercelSettings, err := dbConnector.VercelSettings()
if err != nil {
h.logger.Error("cannot read vercel connector settings", log.Error(err))
return nil
}
return drivers.NewVercelNameResolver(httpClient, vercelSettings.TeamID)
case coredata.ConnectorProviderMonday:
return drivers.NewMondayNameResolver(httpClient)
case coredata.ConnectorProviderLever:
return drivers.NewLeverNameResolver()
case coredata.ConnectorProviderDeel:
return drivers.NewDeelNameResolver(httpClient)
default: default:
return nil return nil
} }