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:
@@ -388,6 +388,48 @@ func (s AccessSourceService) ConfigureAccessSource(
|
||||
}); err != nil {
|
||||
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:
|
||||
return fmt.Errorf("cannot configure access source: provider %s does not support organization configuration", dbConnector.Provider)
|
||||
}
|
||||
|
||||
132
pkg/accessreview/drivers/asana.go
Normal file
132
pkg/accessreview/drivers/asana.go
Normal 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
|
||||
}
|
||||
51
pkg/accessreview/drivers/asana_test.go
Normal file
51
pkg/accessreview/drivers/asana_test.go
Normal 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)
|
||||
}
|
||||
130
pkg/accessreview/drivers/bitbucket.go
Normal file
130
pkg/accessreview/drivers/bitbucket.go
Normal 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
|
||||
}
|
||||
46
pkg/accessreview/drivers/bitbucket_test.go
Normal file
46
pkg/accessreview/drivers/bitbucket_test.go
Normal 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)
|
||||
}
|
||||
111
pkg/accessreview/drivers/cassette_safety_test.go
Normal file
111
pkg/accessreview/drivers/cassette_safety_test.go
Normal 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,
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
154
pkg/accessreview/drivers/clickup.go
Normal file
154
pkg/accessreview/drivers/clickup.go
Normal 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
|
||||
}
|
||||
57
pkg/accessreview/drivers/clickup_test.go
Normal file
57
pkg/accessreview/drivers/clickup_test.go
Normal 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)
|
||||
}
|
||||
135
pkg/accessreview/drivers/deel.go
Normal file
135
pkg/accessreview/drivers/deel.go
Normal 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
|
||||
}
|
||||
49
pkg/accessreview/drivers/deel_test.go
Normal file
49
pkg/accessreview/drivers/deel_test.go
Normal 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)
|
||||
}
|
||||
160
pkg/accessreview/drivers/gitlab.go
Normal file
160
pkg/accessreview/drivers/gitlab.go
Normal 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 ""
|
||||
}
|
||||
}
|
||||
52
pkg/accessreview/drivers/gitlab_test.go
Normal file
52
pkg/accessreview/drivers/gitlab_test.go
Normal 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)
|
||||
}
|
||||
155
pkg/accessreview/drivers/heroku.go
Normal file
155
pkg/accessreview/drivers/heroku.go
Normal 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
|
||||
}
|
||||
52
pkg/accessreview/drivers/heroku_test.go
Normal file
52
pkg/accessreview/drivers/heroku_test.go
Normal 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)
|
||||
}
|
||||
148
pkg/accessreview/drivers/lever.go
Normal file
148
pkg/accessreview/drivers/lever.go
Normal 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
|
||||
}
|
||||
50
pkg/accessreview/drivers/lever_test.go
Normal file
50
pkg/accessreview/drivers/lever_test.go
Normal 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)
|
||||
}
|
||||
172
pkg/accessreview/drivers/monday.go
Normal file
172
pkg/accessreview/drivers/monday.go
Normal 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
|
||||
}
|
||||
51
pkg/accessreview/drivers/monday_test.go
Normal file
51
pkg/accessreview/drivers/monday_test.go
Normal 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)
|
||||
}
|
||||
114
pkg/accessreview/drivers/netlify.go
Normal file
114
pkg/accessreview/drivers/netlify.go
Normal 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
|
||||
}
|
||||
47
pkg/accessreview/drivers/netlify_test.go
Normal file
47
pkg/accessreview/drivers/netlify_test.go
Normal 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)
|
||||
}
|
||||
150
pkg/accessreview/drivers/pagerduty.go
Normal file
150
pkg/accessreview/drivers/pagerduty.go
Normal 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
|
||||
}
|
||||
48
pkg/accessreview/drivers/pagerduty_test.go
Normal file
48
pkg/accessreview/drivers/pagerduty_test.go
Normal 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)
|
||||
}
|
||||
137
pkg/accessreview/drivers/ramp.go
Normal file
137
pkg/accessreview/drivers/ramp.go
Normal 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
|
||||
}
|
||||
50
pkg/accessreview/drivers/ramp_test.go
Normal file
50
pkg/accessreview/drivers/ramp_test.go
Normal 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)
|
||||
}
|
||||
144
pkg/accessreview/drivers/snyk.go
Normal file
144
pkg/accessreview/drivers/snyk.go
Normal 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
|
||||
}
|
||||
47
pkg/accessreview/drivers/snyk_test.go
Normal file
47
pkg/accessreview/drivers/snyk_test.go
Normal 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)
|
||||
}
|
||||
35
pkg/accessreview/drivers/testdata/asana.yaml
vendored
Normal file
35
pkg/accessreview/drivers/testdata/asana.yaml
vendored
Normal 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
|
||||
35
pkg/accessreview/drivers/testdata/bitbucket.yaml
vendored
Normal file
35
pkg/accessreview/drivers/testdata/bitbucket.yaml
vendored
Normal 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
|
||||
30
pkg/accessreview/drivers/testdata/clickup.yaml
vendored
Normal file
30
pkg/accessreview/drivers/testdata/clickup.yaml
vendored
Normal 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
|
||||
67
pkg/accessreview/drivers/testdata/deel.yaml
vendored
Normal file
67
pkg/accessreview/drivers/testdata/deel.yaml
vendored
Normal 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
|
||||
33
pkg/accessreview/drivers/testdata/gitlab.yaml
vendored
Normal file
33
pkg/accessreview/drivers/testdata/gitlab.yaml
vendored
Normal 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
|
||||
30
pkg/accessreview/drivers/testdata/heroku.yaml
vendored
Normal file
30
pkg/accessreview/drivers/testdata/heroku.yaml
vendored
Normal 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
|
||||
33
pkg/accessreview/drivers/testdata/lever.yaml
vendored
Normal file
33
pkg/accessreview/drivers/testdata/lever.yaml
vendored
Normal 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
|
||||
63
pkg/accessreview/drivers/testdata/monday.yaml
vendored
Normal file
63
pkg/accessreview/drivers/testdata/monday.yaml
vendored
Normal 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
|
||||
33
pkg/accessreview/drivers/testdata/netlify.yaml
vendored
Normal file
33
pkg/accessreview/drivers/testdata/netlify.yaml
vendored
Normal 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
|
||||
35
pkg/accessreview/drivers/testdata/pagerduty.yaml
vendored
Normal file
35
pkg/accessreview/drivers/testdata/pagerduty.yaml
vendored
Normal 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
|
||||
33
pkg/accessreview/drivers/testdata/ramp.yaml
vendored
Normal file
33
pkg/accessreview/drivers/testdata/ramp.yaml
vendored
Normal 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
|
||||
35
pkg/accessreview/drivers/testdata/snyk.yaml
vendored
Normal file
35
pkg/accessreview/drivers/testdata/snyk.yaml
vendored
Normal 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
|
||||
33
pkg/accessreview/drivers/testdata/vercel.yaml
vendored
Normal file
33
pkg/accessreview/drivers/testdata/vercel.yaml
vendored
Normal 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
|
||||
147
pkg/accessreview/drivers/vercel.go
Normal file
147
pkg/accessreview/drivers/vercel.go
Normal 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
|
||||
}
|
||||
55
pkg/accessreview/drivers/vercel_test.go
Normal file
55
pkg/accessreview/drivers/vercel_test.go
Normal 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)
|
||||
}
|
||||
@@ -374,6 +374,99 @@ func (e *ReviewEngine) resolveDriver(
|
||||
return drivers.NewResendDriver(httpClient), nil
|
||||
case coredata.ConnectorProviderMicrosoft365:
|
||||
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:
|
||||
return nil, fmt.Errorf("unsupported connector provider %q for access source driver", dbConnector.Provider)
|
||||
}
|
||||
|
||||
@@ -283,6 +283,77 @@ func (h *sourceNameHandler) buildResolver(
|
||||
return drivers.NewResendNameResolver()
|
||||
case coredata.ConnectorProviderMicrosoft365:
|
||||
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:
|
||||
return nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user