From 1532e94eb1a3c238c286804643324e649cc109e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Sibiril?= <81782+aureliensibiril@users.noreply.github.com> Date: Sun, 17 May 2026 17:22:48 +0200 Subject: [PATCH] =?UTF-8?q?Add=2013=20access-review=20driver=20implementat?= =?UTF-8?q?ions=20=E2=86=92=20Dispatch=20new=20providers=20in=20review=20e?= =?UTF-8?q?ngine=20and=20name=20worker?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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> --- pkg/accessreview/access_source_service.go | 42 +++++ pkg/accessreview/drivers/asana.go | 132 ++++++++++++++ pkg/accessreview/drivers/asana_test.go | 51 ++++++ pkg/accessreview/drivers/bitbucket.go | 130 +++++++++++++ pkg/accessreview/drivers/bitbucket_test.go | 46 +++++ .../drivers/cassette_safety_test.go | 111 +++++++++++ pkg/accessreview/drivers/clickup.go | 154 ++++++++++++++++ pkg/accessreview/drivers/clickup_test.go | 57 ++++++ pkg/accessreview/drivers/deel.go | 135 ++++++++++++++ pkg/accessreview/drivers/deel_test.go | 49 +++++ pkg/accessreview/drivers/gitlab.go | 160 ++++++++++++++++ pkg/accessreview/drivers/gitlab_test.go | 52 ++++++ pkg/accessreview/drivers/heroku.go | 155 ++++++++++++++++ pkg/accessreview/drivers/heroku_test.go | 52 ++++++ pkg/accessreview/drivers/lever.go | 148 +++++++++++++++ pkg/accessreview/drivers/lever_test.go | 50 +++++ pkg/accessreview/drivers/monday.go | 172 ++++++++++++++++++ pkg/accessreview/drivers/monday_test.go | 51 ++++++ pkg/accessreview/drivers/netlify.go | 114 ++++++++++++ pkg/accessreview/drivers/netlify_test.go | 47 +++++ pkg/accessreview/drivers/pagerduty.go | 150 +++++++++++++++ pkg/accessreview/drivers/pagerduty_test.go | 48 +++++ pkg/accessreview/drivers/ramp.go | 137 ++++++++++++++ pkg/accessreview/drivers/ramp_test.go | 50 +++++ pkg/accessreview/drivers/snyk.go | 144 +++++++++++++++ pkg/accessreview/drivers/snyk_test.go | 47 +++++ pkg/accessreview/drivers/testdata/asana.yaml | 35 ++++ .../drivers/testdata/bitbucket.yaml | 35 ++++ .../drivers/testdata/clickup.yaml | 30 +++ pkg/accessreview/drivers/testdata/deel.yaml | 67 +++++++ pkg/accessreview/drivers/testdata/gitlab.yaml | 33 ++++ pkg/accessreview/drivers/testdata/heroku.yaml | 30 +++ pkg/accessreview/drivers/testdata/lever.yaml | 33 ++++ pkg/accessreview/drivers/testdata/monday.yaml | 63 +++++++ .../drivers/testdata/netlify.yaml | 33 ++++ .../drivers/testdata/pagerduty.yaml | 35 ++++ pkg/accessreview/drivers/testdata/ramp.yaml | 33 ++++ pkg/accessreview/drivers/testdata/snyk.yaml | 35 ++++ pkg/accessreview/drivers/testdata/vercel.yaml | 33 ++++ pkg/accessreview/drivers/vercel.go | 147 +++++++++++++++ pkg/accessreview/drivers/vercel_test.go | 55 ++++++ pkg/accessreview/review_engine.go | 93 ++++++++++ pkg/accessreview/source_name_worker.go | 71 ++++++++ 43 files changed, 3345 insertions(+) create mode 100644 pkg/accessreview/drivers/asana.go create mode 100644 pkg/accessreview/drivers/asana_test.go create mode 100644 pkg/accessreview/drivers/bitbucket.go create mode 100644 pkg/accessreview/drivers/bitbucket_test.go create mode 100644 pkg/accessreview/drivers/cassette_safety_test.go create mode 100644 pkg/accessreview/drivers/clickup.go create mode 100644 pkg/accessreview/drivers/clickup_test.go create mode 100644 pkg/accessreview/drivers/deel.go create mode 100644 pkg/accessreview/drivers/deel_test.go create mode 100644 pkg/accessreview/drivers/gitlab.go create mode 100644 pkg/accessreview/drivers/gitlab_test.go create mode 100644 pkg/accessreview/drivers/heroku.go create mode 100644 pkg/accessreview/drivers/heroku_test.go create mode 100644 pkg/accessreview/drivers/lever.go create mode 100644 pkg/accessreview/drivers/lever_test.go create mode 100644 pkg/accessreview/drivers/monday.go create mode 100644 pkg/accessreview/drivers/monday_test.go create mode 100644 pkg/accessreview/drivers/netlify.go create mode 100644 pkg/accessreview/drivers/netlify_test.go create mode 100644 pkg/accessreview/drivers/pagerduty.go create mode 100644 pkg/accessreview/drivers/pagerduty_test.go create mode 100644 pkg/accessreview/drivers/ramp.go create mode 100644 pkg/accessreview/drivers/ramp_test.go create mode 100644 pkg/accessreview/drivers/snyk.go create mode 100644 pkg/accessreview/drivers/snyk_test.go create mode 100644 pkg/accessreview/drivers/testdata/asana.yaml create mode 100644 pkg/accessreview/drivers/testdata/bitbucket.yaml create mode 100644 pkg/accessreview/drivers/testdata/clickup.yaml create mode 100644 pkg/accessreview/drivers/testdata/deel.yaml create mode 100644 pkg/accessreview/drivers/testdata/gitlab.yaml create mode 100644 pkg/accessreview/drivers/testdata/heroku.yaml create mode 100644 pkg/accessreview/drivers/testdata/lever.yaml create mode 100644 pkg/accessreview/drivers/testdata/monday.yaml create mode 100644 pkg/accessreview/drivers/testdata/netlify.yaml create mode 100644 pkg/accessreview/drivers/testdata/pagerduty.yaml create mode 100644 pkg/accessreview/drivers/testdata/ramp.yaml create mode 100644 pkg/accessreview/drivers/testdata/snyk.yaml create mode 100644 pkg/accessreview/drivers/testdata/vercel.yaml create mode 100644 pkg/accessreview/drivers/vercel.go create mode 100644 pkg/accessreview/drivers/vercel_test.go diff --git a/pkg/accessreview/access_source_service.go b/pkg/accessreview/access_source_service.go index 09ed91ad0..b81c22471 100644 --- a/pkg/accessreview/access_source_service.go +++ b/pkg/accessreview/access_source_service.go @@ -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) } diff --git a/pkg/accessreview/drivers/asana.go b/pkg/accessreview/drivers/asana.go new file mode 100644 index 000000000..838a62cb1 --- /dev/null +++ b/pkg/accessreview/drivers/asana.go @@ -0,0 +1,132 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 +} diff --git a/pkg/accessreview/drivers/asana_test.go b/pkg/accessreview/drivers/asana_test.go new file mode 100644 index 000000000..568419214 --- /dev/null +++ b/pkg/accessreview/drivers/asana_test.go @@ -0,0 +1,51 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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) +} diff --git a/pkg/accessreview/drivers/bitbucket.go b/pkg/accessreview/drivers/bitbucket.go new file mode 100644 index 000000000..513e793b9 --- /dev/null +++ b/pkg/accessreview/drivers/bitbucket.go @@ -0,0 +1,130 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 +} diff --git a/pkg/accessreview/drivers/bitbucket_test.go b/pkg/accessreview/drivers/bitbucket_test.go new file mode 100644 index 000000000..671e4532f --- /dev/null +++ b/pkg/accessreview/drivers/bitbucket_test.go @@ -0,0 +1,46 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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) +} diff --git a/pkg/accessreview/drivers/cassette_safety_test.go b/pkg/accessreview/drivers/cassette_safety_test.go new file mode 100644 index 000000000..396cc32cd --- /dev/null +++ b/pkg/accessreview/drivers/cassette_safety_test.go @@ -0,0 +1,111 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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, + ) + } + }) + } +} diff --git a/pkg/accessreview/drivers/clickup.go b/pkg/accessreview/drivers/clickup.go new file mode 100644 index 000000000..efc409081 --- /dev/null +++ b/pkg/accessreview/drivers/clickup.go @@ -0,0 +1,154 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 +} diff --git a/pkg/accessreview/drivers/clickup_test.go b/pkg/accessreview/drivers/clickup_test.go new file mode 100644 index 000000000..6cfe51905 --- /dev/null +++ b/pkg/accessreview/drivers/clickup_test.go @@ -0,0 +1,57 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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) +} diff --git a/pkg/accessreview/drivers/deel.go b/pkg/accessreview/drivers/deel.go new file mode 100644 index 000000000..19a182cdc --- /dev/null +++ b/pkg/accessreview/drivers/deel.go @@ -0,0 +1,135 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 +} diff --git a/pkg/accessreview/drivers/deel_test.go b/pkg/accessreview/drivers/deel_test.go new file mode 100644 index 000000000..6a93f9155 --- /dev/null +++ b/pkg/accessreview/drivers/deel_test.go @@ -0,0 +1,49 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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) +} diff --git a/pkg/accessreview/drivers/gitlab.go b/pkg/accessreview/drivers/gitlab.go new file mode 100644 index 000000000..afa915f85 --- /dev/null +++ b/pkg/accessreview/drivers/gitlab.go @@ -0,0 +1,160 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 "" + } +} diff --git a/pkg/accessreview/drivers/gitlab_test.go b/pkg/accessreview/drivers/gitlab_test.go new file mode 100644 index 000000000..b546cda48 --- /dev/null +++ b/pkg/accessreview/drivers/gitlab_test.go @@ -0,0 +1,52 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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) +} diff --git a/pkg/accessreview/drivers/heroku.go b/pkg/accessreview/drivers/heroku.go new file mode 100644 index 000000000..d0fa1375c --- /dev/null +++ b/pkg/accessreview/drivers/heroku.go @@ -0,0 +1,155 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 +} diff --git a/pkg/accessreview/drivers/heroku_test.go b/pkg/accessreview/drivers/heroku_test.go new file mode 100644 index 000000000..17f97c6aa --- /dev/null +++ b/pkg/accessreview/drivers/heroku_test.go @@ -0,0 +1,52 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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) +} diff --git a/pkg/accessreview/drivers/lever.go b/pkg/accessreview/drivers/lever.go new file mode 100644 index 000000000..ae4f23a1b --- /dev/null +++ b/pkg/accessreview/drivers/lever.go @@ -0,0 +1,148 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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=`. +// +// 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 +} diff --git a/pkg/accessreview/drivers/lever_test.go b/pkg/accessreview/drivers/lever_test.go new file mode 100644 index 000000000..c6e8be45c --- /dev/null +++ b/pkg/accessreview/drivers/lever_test.go @@ -0,0 +1,50 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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) +} diff --git a/pkg/accessreview/drivers/monday.go b/pkg/accessreview/drivers/monday.go new file mode 100644 index 000000000..9a186fa41 --- /dev/null +++ b/pkg/accessreview/drivers/monday.go @@ -0,0 +1,172 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 +} diff --git a/pkg/accessreview/drivers/monday_test.go b/pkg/accessreview/drivers/monday_test.go new file mode 100644 index 000000000..0ca9a0763 --- /dev/null +++ b/pkg/accessreview/drivers/monday_test.go @@ -0,0 +1,51 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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) +} diff --git a/pkg/accessreview/drivers/netlify.go b/pkg/accessreview/drivers/netlify.go new file mode 100644 index 000000000..c1f30c28f --- /dev/null +++ b/pkg/accessreview/drivers/netlify.go @@ -0,0 +1,114 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 +} diff --git a/pkg/accessreview/drivers/netlify_test.go b/pkg/accessreview/drivers/netlify_test.go new file mode 100644 index 000000000..b1ab5c4d9 --- /dev/null +++ b/pkg/accessreview/drivers/netlify_test.go @@ -0,0 +1,47 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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) +} diff --git a/pkg/accessreview/drivers/pagerduty.go b/pkg/accessreview/drivers/pagerduty.go new file mode 100644 index 000000000..458eeec8b --- /dev/null +++ b/pkg/accessreview/drivers/pagerduty.go @@ -0,0 +1,150 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 +} diff --git a/pkg/accessreview/drivers/pagerduty_test.go b/pkg/accessreview/drivers/pagerduty_test.go new file mode 100644 index 000000000..6379fefa6 --- /dev/null +++ b/pkg/accessreview/drivers/pagerduty_test.go @@ -0,0 +1,48 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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) +} diff --git a/pkg/accessreview/drivers/ramp.go b/pkg/accessreview/drivers/ramp.go new file mode 100644 index 000000000..25c6f1a76 --- /dev/null +++ b/pkg/accessreview/drivers/ramp.go @@ -0,0 +1,137 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 +} diff --git a/pkg/accessreview/drivers/ramp_test.go b/pkg/accessreview/drivers/ramp_test.go new file mode 100644 index 000000000..d0f08d6cf --- /dev/null +++ b/pkg/accessreview/drivers/ramp_test.go @@ -0,0 +1,50 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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) +} diff --git a/pkg/accessreview/drivers/snyk.go b/pkg/accessreview/drivers/snyk.go new file mode 100644 index 000000000..d0a7d0c61 --- /dev/null +++ b/pkg/accessreview/drivers/snyk.go @@ -0,0 +1,144 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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//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 +} diff --git a/pkg/accessreview/drivers/snyk_test.go b/pkg/accessreview/drivers/snyk_test.go new file mode 100644 index 000000000..93afd84b6 --- /dev/null +++ b/pkg/accessreview/drivers/snyk_test.go @@ -0,0 +1,47 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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) +} diff --git a/pkg/accessreview/drivers/testdata/asana.yaml b/pkg/accessreview/drivers/testdata/asana.yaml new file mode 100644 index 000000000..1482d5f06 --- /dev/null +++ b/pkg/accessreview/drivers/testdata/asana.yaml @@ -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 diff --git a/pkg/accessreview/drivers/testdata/bitbucket.yaml b/pkg/accessreview/drivers/testdata/bitbucket.yaml new file mode 100644 index 000000000..a3b562457 --- /dev/null +++ b/pkg/accessreview/drivers/testdata/bitbucket.yaml @@ -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 diff --git a/pkg/accessreview/drivers/testdata/clickup.yaml b/pkg/accessreview/drivers/testdata/clickup.yaml new file mode 100644 index 000000000..2d7efab79 --- /dev/null +++ b/pkg/accessreview/drivers/testdata/clickup.yaml @@ -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 diff --git a/pkg/accessreview/drivers/testdata/deel.yaml b/pkg/accessreview/drivers/testdata/deel.yaml new file mode 100644 index 000000000..49eda1054 --- /dev/null +++ b/pkg/accessreview/drivers/testdata/deel.yaml @@ -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 diff --git a/pkg/accessreview/drivers/testdata/gitlab.yaml b/pkg/accessreview/drivers/testdata/gitlab.yaml new file mode 100644 index 000000000..8bffb1cd2 --- /dev/null +++ b/pkg/accessreview/drivers/testdata/gitlab.yaml @@ -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 diff --git a/pkg/accessreview/drivers/testdata/heroku.yaml b/pkg/accessreview/drivers/testdata/heroku.yaml new file mode 100644 index 000000000..a01a0fbba --- /dev/null +++ b/pkg/accessreview/drivers/testdata/heroku.yaml @@ -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 diff --git a/pkg/accessreview/drivers/testdata/lever.yaml b/pkg/accessreview/drivers/testdata/lever.yaml new file mode 100644 index 000000000..2fd5b14a9 --- /dev/null +++ b/pkg/accessreview/drivers/testdata/lever.yaml @@ -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 diff --git a/pkg/accessreview/drivers/testdata/monday.yaml b/pkg/accessreview/drivers/testdata/monday.yaml new file mode 100644 index 000000000..c55b2099f --- /dev/null +++ b/pkg/accessreview/drivers/testdata/monday.yaml @@ -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 diff --git a/pkg/accessreview/drivers/testdata/netlify.yaml b/pkg/accessreview/drivers/testdata/netlify.yaml new file mode 100644 index 000000000..8218f6f0d --- /dev/null +++ b/pkg/accessreview/drivers/testdata/netlify.yaml @@ -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 diff --git a/pkg/accessreview/drivers/testdata/pagerduty.yaml b/pkg/accessreview/drivers/testdata/pagerduty.yaml new file mode 100644 index 000000000..8336f918a --- /dev/null +++ b/pkg/accessreview/drivers/testdata/pagerduty.yaml @@ -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 diff --git a/pkg/accessreview/drivers/testdata/ramp.yaml b/pkg/accessreview/drivers/testdata/ramp.yaml new file mode 100644 index 000000000..3370676d4 --- /dev/null +++ b/pkg/accessreview/drivers/testdata/ramp.yaml @@ -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 diff --git a/pkg/accessreview/drivers/testdata/snyk.yaml b/pkg/accessreview/drivers/testdata/snyk.yaml new file mode 100644 index 000000000..8c42734b7 --- /dev/null +++ b/pkg/accessreview/drivers/testdata/snyk.yaml @@ -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 diff --git a/pkg/accessreview/drivers/testdata/vercel.yaml b/pkg/accessreview/drivers/testdata/vercel.yaml new file mode 100644 index 000000000..f189894ce --- /dev/null +++ b/pkg/accessreview/drivers/testdata/vercel.yaml @@ -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 diff --git a/pkg/accessreview/drivers/vercel.go b/pkg/accessreview/drivers/vercel.go new file mode 100644 index 000000000..de84d8942 --- /dev/null +++ b/pkg/accessreview/drivers/vercel.go @@ -0,0 +1,147 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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=` 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 +} diff --git a/pkg/accessreview/drivers/vercel_test.go b/pkg/accessreview/drivers/vercel_test.go new file mode 100644 index 000000000..538d271f9 --- /dev/null +++ b/pkg/accessreview/drivers/vercel_test.go @@ -0,0 +1,55 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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) +} diff --git a/pkg/accessreview/review_engine.go b/pkg/accessreview/review_engine.go index e3ae63cc7..63792efe8 100644 --- a/pkg/accessreview/review_engine.go +++ b/pkg/accessreview/review_engine.go @@ -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) } diff --git a/pkg/accessreview/source_name_worker.go b/pkg/accessreview/source_name_worker.go index ba77588d4..1efc6ba88 100644 --- a/pkg/accessreview/source_name_worker.go +++ b/pkg/accessreview/source_name_worker.go @@ -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 }