Require CLIENT_SECRET for new access-review connectors → Drop Snyk, Ramp, Lever, Deel access-review providers

- Require CLIENT_SECRET for new access-review connectors
- Use Heroku account UUID as ExternalID
- Bump GitHub orgs picker to per_page=100
- Drop Snyk, Ramp, Lever, Deel access-review providers

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:50 +02:00
parent ceacaea34e
commit c2db47e698
40 changed files with 29 additions and 1367 deletions

View File

@@ -1,136 +0,0 @@
// 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))
u := url.URL{Scheme: "https", Host: "api.letsdeel.com", Path: "/rest/v2/people", RawQuery: q.Encode()}
endpoint := u.String()
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

@@ -1,49 +0,0 @@
// 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

@@ -96,6 +96,11 @@ func (d *HerokuDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error
isAdmin := m.Role == "admin" || m.Role == "owner"
externalID := m.User.ID
if externalID == "" {
externalID = m.ID
}
record := AccountRecord{
Email: email,
FullName: fullName,
@@ -104,7 +109,7 @@ func (d *HerokuDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error
MFAStatus: mfaStatus,
AuthMethod: coredata.AccessEntryAuthMethodUnknown,
AccountType: coredata.AccessEntryAccountTypeUser,
ExternalID: email,
ExternalID: externalID,
}
if m.CreatedAt != "" {

View File

@@ -1,149 +0,0 @@
// 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)
}
u := url.URL{Scheme: "https", Host: "api.lever.co", Path: "/v1/users", RawQuery: q.Encode()}
endpoint := u.String()
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

@@ -1,50 +0,0 @@
// 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

@@ -58,14 +58,10 @@ var providerDisplayNames = map[coredata.ConnectorProvider]string{
coredata.ConnectorProviderHeroku: "Heroku",
coredata.ConnectorProviderPagerDuty: "PagerDuty",
coredata.ConnectorProviderAsana: "Asana",
coredata.ConnectorProviderSnyk: "Snyk",
coredata.ConnectorProviderNetlify: "Netlify",
coredata.ConnectorProviderRamp: "Ramp",
coredata.ConnectorProviderClickUp: "ClickUp",
coredata.ConnectorProviderVercel: "Vercel",
coredata.ConnectorProviderMonday: "Monday.com",
coredata.ConnectorProviderLever: "Lever",
coredata.ConnectorProviderDeel: "Deel",
}
// ProviderDisplayName returns the human-readable label for a connector provider.
@@ -802,53 +798,6 @@ func (r *asanaNameResolver) ResolveInstanceName(ctx context.Context) (string, er
return resp.Data.Name, nil
}
// snykNameResolver resolves the Snyk organization name.
type snykNameResolver struct {
httpClient *http.Client
orgID string
}
func NewSnykNameResolver(httpClient *http.Client, orgID string) NameResolver {
return &snykNameResolver{httpClient: httpClient, orgID: orgID}
}
func (r *snykNameResolver) ResolveInstanceName(ctx context.Context) (string, error) {
if r.orgID == "" {
return "", nil
}
endpoint := fmt.Sprintf("https://api.snyk.io/rest/orgs/%s?version=2024-10-15", url.PathEscape(r.orgID))
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return "", fmt.Errorf("cannot create snyk org request: %w", err)
}
req.Header.Set("Accept", "application/vnd.api+json")
httpResp, err := r.httpClient.Do(req)
if err != nil {
return "", fmt.Errorf("cannot execute snyk org request: %w", err)
}
defer func() { _ = httpResp.Body.Close() }()
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
return "", fmt.Errorf("cannot fetch snyk org: unexpected status %d", httpResp.StatusCode)
}
var resp struct {
Data struct {
Attributes struct {
Name string `json:"name"`
} `json:"attributes"`
} `json:"data"`
}
if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil {
return "", fmt.Errorf("cannot decode snyk org response: %w", err)
}
return resp.Data.Attributes.Name, nil
}
// netlifyNameResolver resolves the Netlify account name.
type netlifyNameResolver struct {
httpClient *http.Client
@@ -892,51 +841,6 @@ func (r *netlifyNameResolver) ResolveInstanceName(ctx context.Context) (string,
return resp.Name, nil
}
// rampNameResolver resolves the Ramp business name.
type rampNameResolver struct {
httpClient *http.Client
}
func NewRampNameResolver(httpClient *http.Client) NameResolver {
return &rampNameResolver{httpClient: httpClient}
}
func (r *rampNameResolver) ResolveInstanceName(ctx context.Context) (string, error) {
req, err := http.NewRequestWithContext(
ctx,
http.MethodGet,
"https://api.ramp.com/developer/v1/business",
nil,
)
if err != nil {
return "", fmt.Errorf("cannot create ramp business request: %w", err)
}
req.Header.Set("Accept", "application/json")
httpResp, err := r.httpClient.Do(req)
if err != nil {
return "", fmt.Errorf("cannot execute ramp business request: %w", err)
}
defer func() { _ = httpResp.Body.Close() }()
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
return "", fmt.Errorf("cannot fetch ramp business: unexpected status %d", httpResp.StatusCode)
}
var resp struct {
BusinessName string `json:"business_name"`
LegalBusinessName string `json:"legal_business_name"`
}
if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil {
return "", fmt.Errorf("cannot decode ramp business response: %w", err)
}
if resp.BusinessName != "" {
return resp.BusinessName, nil
}
return resp.LegalBusinessName, nil
}
// clickupNameResolver resolves the ClickUp team name.
type clickupNameResolver struct {
httpClient *http.Client
@@ -1102,67 +1006,6 @@ func (r *mondayNameResolver) ResolveInstanceName(ctx context.Context) (string, e
return resp.Data.Account.Name, nil
}
// leverNameResolver returns an empty string: Lever does not expose a
// dedicated org-name endpoint. The worker keeps the generic name and
// the operator can rename the source manually.
type leverNameResolver struct{}
func NewLeverNameResolver() NameResolver {
return &leverNameResolver{}
}
func (r *leverNameResolver) ResolveInstanceName(_ context.Context) (string, error) {
return "", nil
}
// deelNameResolver resolves the Deel organization name by reading the
// first item of /rest/v2/organizations.
type deelNameResolver struct {
httpClient *http.Client
}
func NewDeelNameResolver(httpClient *http.Client) NameResolver {
return &deelNameResolver{httpClient: httpClient}
}
func (r *deelNameResolver) ResolveInstanceName(ctx context.Context) (string, error) {
req, err := http.NewRequestWithContext(
ctx,
http.MethodGet,
"https://api.letsdeel.com/rest/v2/organizations",
nil,
)
if err != nil {
return "", fmt.Errorf("cannot create deel organizations request: %w", err)
}
req.Header.Set("Accept", "application/json")
httpResp, err := r.httpClient.Do(req)
if err != nil {
return "", fmt.Errorf("cannot execute deel organizations request: %w", err)
}
defer func() { _ = httpResp.Body.Close() }()
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
return "", fmt.Errorf("cannot fetch deel organizations: unexpected status %d", httpResp.StatusCode)
}
var resp struct {
Data []struct {
Name string `json:"name"`
} `json:"data"`
}
if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil {
return "", fmt.Errorf("cannot decode deel organizations response: %w", err)
}
if len(resp.Data) == 0 {
return "", nil
}
return resp.Data[0].Name, nil
}
// notionNameResolver resolves the Notion workspace name via /v1/users/me.
type notionNameResolver struct {
httpClient *http.Client

View File

@@ -45,11 +45,7 @@ var providerOAuth2Scopes = map[coredata.ConnectorProvider][]string{
coredata.ConnectorProviderHeroku: {"read"},
coredata.ConnectorProviderPagerDuty: {"users.read"},
coredata.ConnectorProviderAsana: {"workspaces:read", "users:read"},
coredata.ConnectorProviderSnyk: {"org.read", "org.membership.read", "offline_access"},
coredata.ConnectorProviderRamp: {"users:read"},
coredata.ConnectorProviderMonday: {"users:read", "account:read"},
coredata.ConnectorProviderLever: {"users:read:admin", "offline_access"},
coredata.ConnectorProviderDeel: {"people:read", "organizations:read"},
// Notion and Intercom have no scopes here: Notion authorizes via
// extra-auth-params (owner=user), Intercom configures scopes at the app
// level. Bitbucket scopes are pinned on the OAuth consumer at

View File

@@ -34,7 +34,7 @@ type Organization struct {
// ListGitHubOrganizations fetches the organizations the authenticated
// GitHub user belongs to.
func ListGitHubOrganizations(ctx context.Context, httpClient *http.Client) ([]Organization, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.github.com/user/orgs", nil)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.github.com/user/orgs?per_page=100", nil)
if err != nil {
return nil, fmt.Errorf("cannot create github organizations request: %w", err)
}
@@ -301,58 +301,6 @@ func ListAsanaOrganizations(ctx context.Context, httpClient *http.Client) ([]Org
return result, nil
}
// ListSnykOrganizations fetches the Snyk organizations the authenticated
// user belongs to. The Snyk REST API is JSON:API; the org id is the
// unique identifier surfaced as the slug.
func ListSnykOrganizations(ctx context.Context, httpClient *http.Client) ([]Organization, error) {
req, err := http.NewRequestWithContext(
ctx,
http.MethodGet,
"https://api.snyk.io/rest/orgs?version=2024-10-15&limit=100",
nil,
)
if err != nil {
return nil, fmt.Errorf("cannot create snyk organizations request: %w", err)
}
req.Header.Set("Accept", "application/vnd.api+json")
resp, err := httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("cannot fetch snyk organizations: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("cannot fetch snyk organizations: unexpected status %d", resp.StatusCode)
}
var body struct {
Data []struct {
ID string `json:"id"`
Attributes struct {
Slug string `json:"slug"`
Name string `json:"name"`
} `json:"attributes"`
} `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
return nil, fmt.Errorf("cannot decode snyk organizations response: %w", err)
}
result := make([]Organization, len(body.Data))
for i, org := range body.Data {
displayName := org.Attributes.Name
if displayName == "" {
displayName = org.Attributes.Slug
}
if displayName == "" {
displayName = org.ID
}
result[i] = Organization{Slug: org.ID, DisplayName: displayName}
}
return result, nil
}
// ListNetlifyOrganizations fetches the Netlify accounts the authenticated
// user belongs to.
func ListNetlifyOrganizations(ctx context.Context, httpClient *http.Client) ([]Organization, error) {

View File

@@ -1,137 +0,0 @@
// 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, endpoint string) (*rampUsersPage, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, 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

@@ -1,50 +0,0 @@
// 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

@@ -1,145 +0,0 @@
// 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"
"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",
url.PathEscape(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, endpoint string) (*snykMembershipsPage, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, 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

@@ -1,47 +0,0 @@
// 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

@@ -1,67 +0,0 @@
---
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

@@ -1,33 +0,0 @@
---
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

@@ -1,33 +0,0 @@
---
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

@@ -1,35 +0,0 @@
---
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