Require CLIENT_SECRET for new access-review connectors → Drop Snyk, Ramp, Lever, Deel access-review providers
- Require CLIENT_SECRET for new access-review connectors - Use Heroku account UUID as ExternalID - Bump GitHub orgs picker to per_page=100 - Drop Snyk, Ramp, Lever, Deel access-review providers Signed-off-by: Aurélien Sibiril <81782+aureliensibiril@users.noreply.github.com>
This commit is contained in:
@@ -108,12 +108,8 @@
|
||||
# CONNECTOR_PAGERDUTY_CLIENT_SECRET=
|
||||
# CONNECTOR_ASANA_CLIENT_ID=
|
||||
# CONNECTOR_ASANA_CLIENT_SECRET=
|
||||
# CONNECTOR_SNYK_CLIENT_ID=
|
||||
# CONNECTOR_SNYK_CLIENT_SECRET=
|
||||
# CONNECTOR_NETLIFY_CLIENT_ID=
|
||||
# CONNECTOR_NETLIFY_CLIENT_SECRET=
|
||||
# CONNECTOR_RAMP_CLIENT_ID=
|
||||
# CONNECTOR_RAMP_CLIENT_SECRET=
|
||||
# CONNECTOR_CLICKUP_CLIENT_ID=
|
||||
# CONNECTOR_CLICKUP_CLIENT_SECRET=
|
||||
# CONNECTOR_VERCEL_CLIENT_ID=
|
||||
@@ -121,10 +117,6 @@
|
||||
# CONNECTOR_VERCEL_INTEGRATION_SLUG=
|
||||
# CONNECTOR_MONDAY_CLIENT_ID=
|
||||
# CONNECTOR_MONDAY_CLIENT_SECRET=
|
||||
# CONNECTOR_LEVER_CLIENT_ID=
|
||||
# CONNECTOR_LEVER_CLIENT_SECRET=
|
||||
# CONNECTOR_DEEL_CLIENT_ID=
|
||||
# CONNECTOR_DEEL_CLIENT_SECRET=
|
||||
|
||||
# ── Custom domains (Pebble ACME via compose) ──────────────────────────
|
||||
# CUSTOM_DOMAINS_CNAME_TARGET=custom.getprobo.com
|
||||
|
||||
@@ -283,7 +283,7 @@ spec:
|
||||
{{- end }}
|
||||
# Connectors (access-review OAuth2 providers added 2026)
|
||||
{{- range .Values.probo.connectors }}
|
||||
{{- $accessReviewProviders := list "gitlab" "bitbucket" "heroku" "pagerduty" "asana" "snyk" "netlify" "ramp" "clickup" "vercel" "monday" "lever" "deel" }}
|
||||
{{- $accessReviewProviders := list "gitlab" "bitbucket" "heroku" "pagerduty" "asana" "netlify" "clickup" "vercel" "monday" }}
|
||||
{{- if has .name $accessReviewProviders }}
|
||||
{{- $envPrefix := printf "CONNECTOR_%s" (.name | upper) }}
|
||||
{{- $secretPrefix := printf "connector-%s" .name }}
|
||||
|
||||
@@ -56,7 +56,7 @@ stringData:
|
||||
connector-slack-signing-secret: {{ .config.signingSecret | quote }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- $accessReviewProviders := list "gitlab" "bitbucket" "heroku" "pagerduty" "asana" "snyk" "netlify" "ramp" "clickup" "vercel" "monday" "lever" "deel" }}
|
||||
{{- $accessReviewProviders := list "gitlab" "bitbucket" "heroku" "pagerduty" "asana" "netlify" "clickup" "vercel" "monday" }}
|
||||
{{- if has .name $accessReviewProviders }}
|
||||
{{- if .config.clientId }}
|
||||
connector-{{ .name }}-client-id: {{ .config.clientId | quote }}
|
||||
|
||||
@@ -185,7 +185,7 @@ probo:
|
||||
# Supported providers (set name to one of):
|
||||
# slack, hubspot, docusign, notion, github, sentry, intercom, brex,
|
||||
# google_workspace, gitlab, bitbucket, heroku, pagerduty, asana,
|
||||
# snyk, netlify, ramp, clickup, vercel, monday, lever, deel
|
||||
# netlify, clickup, vercel, monday
|
||||
#
|
||||
# Vercel additionally requires "integration-slug" so the templated
|
||||
# AuthURL ("https://vercel.com/integrations/{integration_slug}/new")
|
||||
|
||||
@@ -27,13 +27,12 @@ import (
|
||||
const testCsvData = "email,full_name,role,job_title,is_admin,mfa_status,auth_method,last_login,account_created_at,external_id\njane@example.com,Jane Smith,admin,CTO,true,ENABLED,SSO,2026-01-15T00:00:00Z,2024-06-01T00:00:00Z,ext-jane"
|
||||
|
||||
// TODO(access-review-connectors): add e2e tests for the new GitLab,
|
||||
// Bitbucket, Heroku, PagerDuty, Asana, Snyk, Netlify, Ramp, ClickUp,
|
||||
// Vercel, Monday.com, Lever, and Deel access-source connectors once the
|
||||
// e2e factory grows OAuth2-connector seed support for arbitrary
|
||||
// providers. For now the per-driver VCR tests under
|
||||
// pkg/accessreview/drivers cover the fetch path; this e2e suite still
|
||||
// exercises CSV + Slack which were the providers wired before these
|
||||
// sprints.
|
||||
// Bitbucket, Heroku, PagerDuty, Asana, Netlify, ClickUp, Vercel, and
|
||||
// Monday.com access-source connectors once the e2e factory grows
|
||||
// OAuth2-connector seed support for arbitrary providers. For now the
|
||||
// per-driver VCR tests under pkg/accessreview/drivers cover the fetch
|
||||
// path; this e2e suite still exercises CSV + Slack which were the
|
||||
// providers wired before these sprints.
|
||||
func TestAccessSource_Create(t *testing.T) {
|
||||
t.Parallel()
|
||||
owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
import type { ComponentProps } from "react";
|
||||
|
||||
export function Deel(props: ComponentProps<"svg">) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" {...props}>
|
||||
<path
|
||||
d="M3 3h7.5c4.97 0 9 4.03 9 9s-4.03 9-9 9H3V3zm3.5 3.2v11.6h4c3.2 0 5.8-2.6 5.8-5.8S13.7 6.2 10.5 6.2h-4z"
|
||||
fill="#15847B"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import type { ComponentProps } from "react";
|
||||
|
||||
export function Lever(props: ComponentProps<"svg">) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" {...props}>
|
||||
<path
|
||||
d="M3 3h3.6v14.8H21V21H3V3zm6.4 0h3.6v11.2h7V17.5H9.4V3z"
|
||||
fill="#16323F"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import type { ComponentProps } from "react";
|
||||
|
||||
export function Ramp(props: ComponentProps<"svg">) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" {...props}>
|
||||
<path
|
||||
d="M3 3h11.5c2.76 0 5 2.24 5 5 0 2.05-1.23 3.81-2.99 4.59L20 21h-3.7l-3.16-7.5H6.5V21H3V3zm3.5 3.2v4.1h7.6c1.13 0 2.05-.92 2.05-2.05S15.23 6.2 14.1 6.2H6.5z"
|
||||
fill="#FFCC33"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import type { ComponentProps } from "react";
|
||||
|
||||
export function Snyk(props: ComponentProps<"svg">) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" {...props}>
|
||||
<path
|
||||
d="M22.226 7.563c-.876-1.518-3.351-1.519-4.227 0l-1.252 2.169-2.633-4.561c-.876-1.518-3.351-1.518-4.227 0L8.012 9.928 6.137 6.68c-.624-1.083-2.394-1.085-3.02 0L.328 11.32c-.626 1.085.26 2.612 1.51 2.612h3.748l-1.872 3.243c-.626 1.085.26 2.612 1.51 2.612h13.553c1.25 0 2.135-1.527 1.51-2.612l-1.873-3.243h2.103c1.25 0 2.137-1.527 1.51-2.612z"
|
||||
fill="#4C4A73"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -19,7 +19,6 @@ import { Bitbucket } from "./Bitbucket";
|
||||
import { Brex } from "./Brex";
|
||||
import { ClickUp } from "./ClickUp";
|
||||
import { Cloudflare } from "./Cloudflare";
|
||||
import { Deel } from "./Deel";
|
||||
import { DocuSign } from "./DocuSign";
|
||||
import { Figma } from "./Figma";
|
||||
import { GitHub } from "./GitHub";
|
||||
@@ -28,7 +27,6 @@ import { Google } from "./Google";
|
||||
import { Heroku } from "./Heroku";
|
||||
import { HubSpot } from "./HubSpot";
|
||||
import { Intercom } from "./Intercom";
|
||||
import { Lever } from "./Lever";
|
||||
import { Linear } from "./Linear";
|
||||
import { Microsoft } from "./Microsoft";
|
||||
import { Monday } from "./Monday";
|
||||
@@ -37,11 +35,9 @@ import { Notion } from "./Notion";
|
||||
import { OnePassword } from "./OnePassword";
|
||||
import { OpenAI } from "./OpenAI";
|
||||
import { PagerDuty } from "./PagerDuty";
|
||||
import { Ramp } from "./Ramp";
|
||||
import { Resend } from "./Resend";
|
||||
import { Sentry } from "./Sentry";
|
||||
import { Slack } from "./Slack";
|
||||
import { Snyk } from "./Snyk";
|
||||
import { Supabase } from "./Supabase";
|
||||
import { Tally } from "./Tally";
|
||||
import { Vercel } from "./Vercel";
|
||||
@@ -52,7 +48,6 @@ const thirdParties: Record<string, FC<ComponentProps<"svg">>> = {
|
||||
BREX: Brex,
|
||||
CLICKUP: ClickUp,
|
||||
CLOUDFLARE: Cloudflare,
|
||||
DEEL: Deel,
|
||||
DOCUSIGN: DocuSign,
|
||||
FIGMA: Figma,
|
||||
GITHUB: GitHub,
|
||||
@@ -62,7 +57,6 @@ const thirdParties: Record<string, FC<ComponentProps<"svg">>> = {
|
||||
HEROKU: Heroku,
|
||||
HUBSPOT: HubSpot,
|
||||
INTERCOM: Intercom,
|
||||
LEVER: Lever,
|
||||
LINEAR: Linear,
|
||||
MICROSOFT: Microsoft,
|
||||
MICROSOFT_365: Microsoft,
|
||||
@@ -73,11 +67,9 @@ const thirdParties: Record<string, FC<ComponentProps<"svg">>> = {
|
||||
ONEPASSWORD: OnePassword,
|
||||
OPENAI: OpenAI,
|
||||
PAGERDUTY: PagerDuty,
|
||||
RAMP: Ramp,
|
||||
RESEND: Resend,
|
||||
SENTRY: Sentry,
|
||||
SLACK: Slack,
|
||||
SNYK: Snyk,
|
||||
SUPABASE: Supabase,
|
||||
TALLY: Tally,
|
||||
VERCEL: Vercel,
|
||||
|
||||
@@ -3,7 +3,6 @@ export { Bitbucket } from "./Bitbucket";
|
||||
export { Brex } from "./Brex";
|
||||
export { ClickUp } from "./ClickUp";
|
||||
export { Cloudflare } from "./Cloudflare";
|
||||
export { Deel } from "./Deel";
|
||||
export { DocuSign } from "./DocuSign";
|
||||
export { Figma } from "./Figma";
|
||||
export { GitHub } from "./GitHub";
|
||||
@@ -12,7 +11,6 @@ export { Google } from "./Google";
|
||||
export { Heroku } from "./Heroku";
|
||||
export { HubSpot } from "./HubSpot";
|
||||
export { Intercom } from "./Intercom";
|
||||
export { Lever } from "./Lever";
|
||||
export { Linear } from "./Linear";
|
||||
export { Microsoft } from "./Microsoft";
|
||||
export { Monday } from "./Monday";
|
||||
@@ -21,11 +19,9 @@ export { Notion } from "./Notion";
|
||||
export { OnePassword } from "./OnePassword";
|
||||
export { OpenAI } from "./OpenAI";
|
||||
export { PagerDuty } from "./PagerDuty";
|
||||
export { Ramp } from "./Ramp";
|
||||
export { Resend } from "./Resend";
|
||||
export { Sentry } from "./Sentry";
|
||||
export { Slack } from "./Slack";
|
||||
export { Snyk } from "./Snyk";
|
||||
export { Supabase } from "./Supabase";
|
||||
export { Tally } from "./Tally";
|
||||
export { ThirdPartyLogo } from "./ThirdPartyLogo";
|
||||
|
||||
@@ -412,12 +412,6 @@ func (s AccessSourceService) ConfigureAccessSource(
|
||||
}); 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,
|
||||
|
||||
@@ -1,136 +0,0 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package drivers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"go.probo.inc/probo/pkg/coredata"
|
||||
)
|
||||
|
||||
// DeelDriver fetches people from the Deel REST API using a
|
||||
// pre-authenticated HTTP client (Bearer token). Pagination is
|
||||
// offset-based: increment `offset` by `limit` until the response
|
||||
// `data` array is empty.
|
||||
//
|
||||
// Notes on data quality:
|
||||
// - Active is derived from `hiring_status == "active"`. When `end_date`
|
||||
// is set the worker is considered inactive regardless of hiring_status.
|
||||
// - MFA and last-login are not exposed by the people endpoint.
|
||||
type DeelDriver struct {
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
var _ Driver = (*DeelDriver)(nil)
|
||||
|
||||
func NewDeelDriver(httpClient *http.Client) *DeelDriver {
|
||||
return &DeelDriver{httpClient: httpClient}
|
||||
}
|
||||
|
||||
type deelPerson struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
FirstName string `json:"first_name"`
|
||||
LastName string `json:"last_name"`
|
||||
JobTitle string `json:"job_title"`
|
||||
HiringStatus string `json:"hiring_status"`
|
||||
StartDate string `json:"start_date"`
|
||||
EndDate string `json:"end_date"`
|
||||
}
|
||||
|
||||
type deelPeoplePage struct {
|
||||
Data []deelPerson `json:"data"`
|
||||
}
|
||||
|
||||
func (d *DeelDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error) {
|
||||
var records []AccountRecord
|
||||
|
||||
const limit = 100
|
||||
offset := 0
|
||||
|
||||
for range maxPaginationPages {
|
||||
people, err := d.queryPeople(ctx, offset, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(people) == 0 {
|
||||
return records, nil
|
||||
}
|
||||
|
||||
for _, p := range people {
|
||||
fullName := strings.TrimSpace(p.FirstName + " " + p.LastName)
|
||||
|
||||
active := p.HiringStatus == "active"
|
||||
if p.EndDate != "" {
|
||||
active = false
|
||||
}
|
||||
|
||||
record := AccountRecord{
|
||||
Email: p.Email,
|
||||
FullName: fullName,
|
||||
JobTitle: p.JobTitle,
|
||||
Active: &active,
|
||||
MFAStatus: coredata.MFAStatusUnknown,
|
||||
AuthMethod: coredata.AccessEntryAuthMethodUnknown,
|
||||
AccountType: coredata.AccessEntryAccountTypeUser,
|
||||
ExternalID: p.ID,
|
||||
}
|
||||
|
||||
records = append(records, record)
|
||||
}
|
||||
|
||||
offset += limit
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("cannot list all deel accounts: %w", ErrPaginationLimitReached)
|
||||
}
|
||||
|
||||
func (d *DeelDriver) queryPeople(ctx context.Context, offset, limit int) ([]deelPerson, error) {
|
||||
q := url.Values{}
|
||||
q.Set("limit", strconv.Itoa(limit))
|
||||
q.Set("offset", strconv.Itoa(offset))
|
||||
u := url.URL{Scheme: "https", Host: "api.letsdeel.com", Path: "/rest/v2/people", RawQuery: q.Encode()}
|
||||
endpoint := u.String()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot create deel people request: %w", err)
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
httpResp, err := d.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot execute deel people request: %w", err)
|
||||
}
|
||||
defer func() { _ = httpResp.Body.Close() }()
|
||||
|
||||
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("cannot fetch deel people: unexpected status %d", httpResp.StatusCode)
|
||||
}
|
||||
|
||||
var page deelPeoplePage
|
||||
if err := json.NewDecoder(httpResp.Body).Decode(&page); err != nil {
|
||||
return nil, fmt.Errorf("cannot decode deel people response: %w", err)
|
||||
}
|
||||
|
||||
return page.Data, nil
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package drivers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestDeelDriver(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rec := newRecorder(t, "testdata/deel", "DEEL_TOKEN")
|
||||
client := newVCRClient(rec, bearerAuth(os.Getenv("DEEL_TOKEN")))
|
||||
|
||||
driver := NewDeelDriver(client)
|
||||
records, err := driver.ListAccounts(context.Background())
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, records)
|
||||
|
||||
r := records[0]
|
||||
assert.NotEmpty(t, r.Email)
|
||||
assert.NotEmpty(t, r.ExternalID)
|
||||
assert.NotEmpty(t, r.FullName)
|
||||
assert.NotEmpty(t, r.JobTitle)
|
||||
require.NotNil(t, r.Active)
|
||||
assert.True(t, *r.Active)
|
||||
|
||||
// Inactive (or end_date set) people surface as Active=false.
|
||||
require.Len(t, records, 2)
|
||||
require.NotNil(t, records[1].Active)
|
||||
assert.False(t, *records[1].Active)
|
||||
}
|
||||
@@ -96,6 +96,11 @@ func (d *HerokuDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error
|
||||
|
||||
isAdmin := m.Role == "admin" || m.Role == "owner"
|
||||
|
||||
externalID := m.User.ID
|
||||
if externalID == "" {
|
||||
externalID = m.ID
|
||||
}
|
||||
|
||||
record := AccountRecord{
|
||||
Email: email,
|
||||
FullName: fullName,
|
||||
@@ -104,7 +109,7 @@ func (d *HerokuDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error
|
||||
MFAStatus: mfaStatus,
|
||||
AuthMethod: coredata.AccessEntryAuthMethodUnknown,
|
||||
AccountType: coredata.AccessEntryAccountTypeUser,
|
||||
ExternalID: email,
|
||||
ExternalID: externalID,
|
||||
}
|
||||
|
||||
if m.CreatedAt != "" {
|
||||
|
||||
@@ -1,149 +0,0 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package drivers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"go.probo.inc/probo/pkg/coredata"
|
||||
)
|
||||
|
||||
// LeverDriver fetches users from the Lever REST API using a
|
||||
// pre-authenticated HTTP client (Bearer token from the Auth0-backed
|
||||
// flow). Pagination is body-cursor based: response carries `data[]`,
|
||||
// `hasNext` (bool), and `next` (cursor). The next request appends
|
||||
// `?offset=<cursor>`.
|
||||
//
|
||||
// Notes on data quality:
|
||||
// - `lastLoggedInAt` and `createdAt` are epoch milliseconds —
|
||||
// defensive parse, may be null/undocumented.
|
||||
// - MFA is exposed only via SCIM/SSO, not the REST API.
|
||||
// - Active is derived from `deactivatedAt`: nil/missing = active.
|
||||
type LeverDriver struct {
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
var _ Driver = (*LeverDriver)(nil)
|
||||
|
||||
func NewLeverDriver(httpClient *http.Client) *LeverDriver {
|
||||
return &LeverDriver{
|
||||
httpClient: &http.Client{
|
||||
Transport: &retryRoundTripper{
|
||||
next: httpClient.Transport,
|
||||
maxRetries: 3,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type leverUser struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name"`
|
||||
AccessRole string `json:"accessRole"`
|
||||
DeactivatedAt *int64 `json:"deactivatedAt"`
|
||||
LastLoggedInAt *int64 `json:"lastLoggedInAt"`
|
||||
CreatedAt *int64 `json:"createdAt"`
|
||||
}
|
||||
|
||||
type leverUsersPage struct {
|
||||
Data []leverUser `json:"data"`
|
||||
HasNext bool `json:"hasNext"`
|
||||
Next string `json:"next"`
|
||||
}
|
||||
|
||||
func (d *LeverDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error) {
|
||||
var records []AccountRecord
|
||||
|
||||
cursor := ""
|
||||
for range maxPaginationPages {
|
||||
page, err := d.queryUsers(ctx, cursor)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, u := range page.Data {
|
||||
active := u.DeactivatedAt == nil
|
||||
|
||||
record := AccountRecord{
|
||||
Email: u.Email,
|
||||
FullName: u.Name,
|
||||
Role: u.AccessRole,
|
||||
Active: &active,
|
||||
MFAStatus: coredata.MFAStatusUnknown,
|
||||
AuthMethod: coredata.AccessEntryAuthMethodUnknown,
|
||||
AccountType: coredata.AccessEntryAccountTypeUser,
|
||||
ExternalID: u.ID,
|
||||
}
|
||||
|
||||
if u.LastLoggedInAt != nil {
|
||||
t := time.UnixMilli(*u.LastLoggedInAt)
|
||||
record.LastLogin = &t
|
||||
}
|
||||
|
||||
if u.CreatedAt != nil {
|
||||
t := time.UnixMilli(*u.CreatedAt)
|
||||
record.CreatedAt = &t
|
||||
}
|
||||
|
||||
records = append(records, record)
|
||||
}
|
||||
|
||||
if !page.HasNext || page.Next == "" {
|
||||
return records, nil
|
||||
}
|
||||
cursor = page.Next
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("cannot list all lever accounts: %w", ErrPaginationLimitReached)
|
||||
}
|
||||
|
||||
func (d *LeverDriver) queryUsers(ctx context.Context, cursor string) (*leverUsersPage, error) {
|
||||
q := url.Values{}
|
||||
q.Set("limit", "100")
|
||||
if cursor != "" {
|
||||
q.Set("offset", cursor)
|
||||
}
|
||||
u := url.URL{Scheme: "https", Host: "api.lever.co", Path: "/v1/users", RawQuery: q.Encode()}
|
||||
endpoint := u.String()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot create lever users request: %w", err)
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
httpResp, err := d.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot execute lever users request: %w", err)
|
||||
}
|
||||
defer func() { _ = httpResp.Body.Close() }()
|
||||
|
||||
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("cannot fetch lever users: unexpected status %d", httpResp.StatusCode)
|
||||
}
|
||||
|
||||
var page leverUsersPage
|
||||
if err := json.NewDecoder(httpResp.Body).Decode(&page); err != nil {
|
||||
return nil, fmt.Errorf("cannot decode lever users response: %w", err)
|
||||
}
|
||||
|
||||
return &page, nil
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package drivers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestLeverDriver(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rec := newRecorder(t, "testdata/lever", "LEVER_TOKEN")
|
||||
client := newVCRClient(rec, bearerAuth(os.Getenv("LEVER_TOKEN")))
|
||||
|
||||
driver := NewLeverDriver(client)
|
||||
records, err := driver.ListAccounts(context.Background())
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, records)
|
||||
|
||||
r := records[0]
|
||||
assert.NotEmpty(t, r.Email)
|
||||
assert.NotEmpty(t, r.ExternalID)
|
||||
assert.NotEmpty(t, r.FullName)
|
||||
assert.NotEmpty(t, r.Role)
|
||||
require.NotNil(t, r.Active)
|
||||
assert.True(t, *r.Active)
|
||||
require.NotNil(t, r.LastLogin)
|
||||
|
||||
// Deactivated users (deactivatedAt non-null) should surface as Active=false.
|
||||
require.Len(t, records, 2)
|
||||
require.NotNil(t, records[1].Active)
|
||||
assert.False(t, *records[1].Active)
|
||||
}
|
||||
@@ -58,14 +58,10 @@ var providerDisplayNames = map[coredata.ConnectorProvider]string{
|
||||
coredata.ConnectorProviderHeroku: "Heroku",
|
||||
coredata.ConnectorProviderPagerDuty: "PagerDuty",
|
||||
coredata.ConnectorProviderAsana: "Asana",
|
||||
coredata.ConnectorProviderSnyk: "Snyk",
|
||||
coredata.ConnectorProviderNetlify: "Netlify",
|
||||
coredata.ConnectorProviderRamp: "Ramp",
|
||||
coredata.ConnectorProviderClickUp: "ClickUp",
|
||||
coredata.ConnectorProviderVercel: "Vercel",
|
||||
coredata.ConnectorProviderMonday: "Monday.com",
|
||||
coredata.ConnectorProviderLever: "Lever",
|
||||
coredata.ConnectorProviderDeel: "Deel",
|
||||
}
|
||||
|
||||
// ProviderDisplayName returns the human-readable label for a connector provider.
|
||||
@@ -802,53 +798,6 @@ func (r *asanaNameResolver) ResolveInstanceName(ctx context.Context) (string, er
|
||||
return resp.Data.Name, nil
|
||||
}
|
||||
|
||||
// snykNameResolver resolves the Snyk organization name.
|
||||
type snykNameResolver struct {
|
||||
httpClient *http.Client
|
||||
orgID string
|
||||
}
|
||||
|
||||
func NewSnykNameResolver(httpClient *http.Client, orgID string) NameResolver {
|
||||
return &snykNameResolver{httpClient: httpClient, orgID: orgID}
|
||||
}
|
||||
|
||||
func (r *snykNameResolver) ResolveInstanceName(ctx context.Context) (string, error) {
|
||||
if r.orgID == "" {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
endpoint := fmt.Sprintf("https://api.snyk.io/rest/orgs/%s?version=2024-10-15", url.PathEscape(r.orgID))
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot create snyk org request: %w", err)
|
||||
}
|
||||
req.Header.Set("Accept", "application/vnd.api+json")
|
||||
|
||||
httpResp, err := r.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot execute snyk org request: %w", err)
|
||||
}
|
||||
defer func() { _ = httpResp.Body.Close() }()
|
||||
|
||||
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
|
||||
return "", fmt.Errorf("cannot fetch snyk org: unexpected status %d", httpResp.StatusCode)
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Data struct {
|
||||
Attributes struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"attributes"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil {
|
||||
return "", fmt.Errorf("cannot decode snyk org response: %w", err)
|
||||
}
|
||||
|
||||
return resp.Data.Attributes.Name, nil
|
||||
}
|
||||
|
||||
// netlifyNameResolver resolves the Netlify account name.
|
||||
type netlifyNameResolver struct {
|
||||
httpClient *http.Client
|
||||
@@ -892,51 +841,6 @@ func (r *netlifyNameResolver) ResolveInstanceName(ctx context.Context) (string,
|
||||
return resp.Name, nil
|
||||
}
|
||||
|
||||
// rampNameResolver resolves the Ramp business name.
|
||||
type rampNameResolver struct {
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
func NewRampNameResolver(httpClient *http.Client) NameResolver {
|
||||
return &rampNameResolver{httpClient: httpClient}
|
||||
}
|
||||
|
||||
func (r *rampNameResolver) ResolveInstanceName(ctx context.Context) (string, error) {
|
||||
req, err := http.NewRequestWithContext(
|
||||
ctx,
|
||||
http.MethodGet,
|
||||
"https://api.ramp.com/developer/v1/business",
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot create ramp business request: %w", err)
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
httpResp, err := r.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot execute ramp business request: %w", err)
|
||||
}
|
||||
defer func() { _ = httpResp.Body.Close() }()
|
||||
|
||||
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
|
||||
return "", fmt.Errorf("cannot fetch ramp business: unexpected status %d", httpResp.StatusCode)
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
BusinessName string `json:"business_name"`
|
||||
LegalBusinessName string `json:"legal_business_name"`
|
||||
}
|
||||
if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil {
|
||||
return "", fmt.Errorf("cannot decode ramp business response: %w", err)
|
||||
}
|
||||
|
||||
if resp.BusinessName != "" {
|
||||
return resp.BusinessName, nil
|
||||
}
|
||||
return resp.LegalBusinessName, nil
|
||||
}
|
||||
|
||||
// clickupNameResolver resolves the ClickUp team name.
|
||||
type clickupNameResolver struct {
|
||||
httpClient *http.Client
|
||||
@@ -1102,67 +1006,6 @@ func (r *mondayNameResolver) ResolveInstanceName(ctx context.Context) (string, e
|
||||
return resp.Data.Account.Name, nil
|
||||
}
|
||||
|
||||
// leverNameResolver returns an empty string: Lever does not expose a
|
||||
// dedicated org-name endpoint. The worker keeps the generic name and
|
||||
// the operator can rename the source manually.
|
||||
type leverNameResolver struct{}
|
||||
|
||||
func NewLeverNameResolver() NameResolver {
|
||||
return &leverNameResolver{}
|
||||
}
|
||||
|
||||
func (r *leverNameResolver) ResolveInstanceName(_ context.Context) (string, error) {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// deelNameResolver resolves the Deel organization name by reading the
|
||||
// first item of /rest/v2/organizations.
|
||||
type deelNameResolver struct {
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
func NewDeelNameResolver(httpClient *http.Client) NameResolver {
|
||||
return &deelNameResolver{httpClient: httpClient}
|
||||
}
|
||||
|
||||
func (r *deelNameResolver) ResolveInstanceName(ctx context.Context) (string, error) {
|
||||
req, err := http.NewRequestWithContext(
|
||||
ctx,
|
||||
http.MethodGet,
|
||||
"https://api.letsdeel.com/rest/v2/organizations",
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot create deel organizations request: %w", err)
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
httpResp, err := r.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot execute deel organizations request: %w", err)
|
||||
}
|
||||
defer func() { _ = httpResp.Body.Close() }()
|
||||
|
||||
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
|
||||
return "", fmt.Errorf("cannot fetch deel organizations: unexpected status %d", httpResp.StatusCode)
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Data []struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil {
|
||||
return "", fmt.Errorf("cannot decode deel organizations response: %w", err)
|
||||
}
|
||||
|
||||
if len(resp.Data) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
return resp.Data[0].Name, nil
|
||||
}
|
||||
|
||||
// notionNameResolver resolves the Notion workspace name via /v1/users/me.
|
||||
type notionNameResolver struct {
|
||||
httpClient *http.Client
|
||||
|
||||
@@ -45,11 +45,7 @@ var providerOAuth2Scopes = map[coredata.ConnectorProvider][]string{
|
||||
coredata.ConnectorProviderHeroku: {"read"},
|
||||
coredata.ConnectorProviderPagerDuty: {"users.read"},
|
||||
coredata.ConnectorProviderAsana: {"workspaces:read", "users:read"},
|
||||
coredata.ConnectorProviderSnyk: {"org.read", "org.membership.read", "offline_access"},
|
||||
coredata.ConnectorProviderRamp: {"users:read"},
|
||||
coredata.ConnectorProviderMonday: {"users:read", "account:read"},
|
||||
coredata.ConnectorProviderLever: {"users:read:admin", "offline_access"},
|
||||
coredata.ConnectorProviderDeel: {"people:read", "organizations:read"},
|
||||
// Notion and Intercom have no scopes here: Notion authorizes via
|
||||
// extra-auth-params (owner=user), Intercom configures scopes at the app
|
||||
// level. Bitbucket scopes are pinned on the OAuth consumer at
|
||||
|
||||
@@ -34,7 +34,7 @@ type Organization struct {
|
||||
// ListGitHubOrganizations fetches the organizations the authenticated
|
||||
// GitHub user belongs to.
|
||||
func ListGitHubOrganizations(ctx context.Context, httpClient *http.Client) ([]Organization, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.github.com/user/orgs", nil)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.github.com/user/orgs?per_page=100", nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot create github organizations request: %w", err)
|
||||
}
|
||||
@@ -301,58 +301,6 @@ func ListAsanaOrganizations(ctx context.Context, httpClient *http.Client) ([]Org
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ListSnykOrganizations fetches the Snyk organizations the authenticated
|
||||
// user belongs to. The Snyk REST API is JSON:API; the org id is the
|
||||
// unique identifier surfaced as the slug.
|
||||
func ListSnykOrganizations(ctx context.Context, httpClient *http.Client) ([]Organization, error) {
|
||||
req, err := http.NewRequestWithContext(
|
||||
ctx,
|
||||
http.MethodGet,
|
||||
"https://api.snyk.io/rest/orgs?version=2024-10-15&limit=100",
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot create snyk organizations request: %w", err)
|
||||
}
|
||||
req.Header.Set("Accept", "application/vnd.api+json")
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot fetch snyk organizations: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("cannot fetch snyk organizations: unexpected status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Data []struct {
|
||||
ID string `json:"id"`
|
||||
Attributes struct {
|
||||
Slug string `json:"slug"`
|
||||
Name string `json:"name"`
|
||||
} `json:"attributes"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
|
||||
return nil, fmt.Errorf("cannot decode snyk organizations response: %w", err)
|
||||
}
|
||||
|
||||
result := make([]Organization, len(body.Data))
|
||||
for i, org := range body.Data {
|
||||
displayName := org.Attributes.Name
|
||||
if displayName == "" {
|
||||
displayName = org.Attributes.Slug
|
||||
}
|
||||
if displayName == "" {
|
||||
displayName = org.ID
|
||||
}
|
||||
result[i] = Organization{Slug: org.ID, DisplayName: displayName}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ListNetlifyOrganizations fetches the Netlify accounts the authenticated
|
||||
// user belongs to.
|
||||
func ListNetlifyOrganizations(ctx context.Context, httpClient *http.Client) ([]Organization, error) {
|
||||
|
||||
@@ -1,137 +0,0 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package drivers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.probo.inc/probo/pkg/coredata"
|
||||
)
|
||||
|
||||
// RampDriver fetches users from the Ramp Developer API using a
|
||||
// pre-authenticated HTTP client (Bearer token). Ramp grants are scoped
|
||||
// to a single business — there is no per-business picker, so this is a
|
||||
// Pattern 1 driver. Pagination is via the absolute URL exposed in
|
||||
// `page.next` on the response body.
|
||||
type RampDriver struct {
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
var _ Driver = (*RampDriver)(nil)
|
||||
|
||||
func NewRampDriver(httpClient *http.Client) *RampDriver {
|
||||
return &RampDriver{
|
||||
httpClient: &http.Client{
|
||||
Transport: &retryRoundTripper{
|
||||
next: httpClient.Transport,
|
||||
maxRetries: 3,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type rampUser struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
FirstName string `json:"first_name"`
|
||||
LastName string `json:"last_name"`
|
||||
Role string `json:"role"`
|
||||
Status string `json:"status"`
|
||||
LastLoginAt string `json:"last_login_at"`
|
||||
IsManager bool `json:"is_manager"`
|
||||
}
|
||||
|
||||
type rampUsersPage struct {
|
||||
Data []rampUser `json:"data"`
|
||||
Page struct {
|
||||
Next string `json:"next"`
|
||||
} `json:"page"`
|
||||
}
|
||||
|
||||
func (d *RampDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error) {
|
||||
var records []AccountRecord
|
||||
|
||||
next := "https://api.ramp.com/developer/v1/users?page_size=100"
|
||||
|
||||
for range maxPaginationPages {
|
||||
page, err := d.queryUsers(ctx, next)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, u := range page.Data {
|
||||
fullName := strings.TrimSpace(u.FirstName + " " + u.LastName)
|
||||
|
||||
active := u.Status == "USER_ACTIVE"
|
||||
|
||||
record := AccountRecord{
|
||||
Email: u.Email,
|
||||
FullName: fullName,
|
||||
Role: u.Role,
|
||||
Active: &active,
|
||||
IsAdmin: u.IsManager,
|
||||
ExternalID: u.ID,
|
||||
MFAStatus: coredata.MFAStatusUnknown,
|
||||
AuthMethod: coredata.AccessEntryAuthMethodUnknown,
|
||||
AccountType: coredata.AccessEntryAccountTypeUser,
|
||||
}
|
||||
|
||||
if u.LastLoginAt != "" {
|
||||
if t, err := time.Parse(time.RFC3339, u.LastLoginAt); err == nil {
|
||||
record.LastLogin = &t
|
||||
}
|
||||
}
|
||||
|
||||
records = append(records, record)
|
||||
}
|
||||
|
||||
if page.Page.Next == "" {
|
||||
return records, nil
|
||||
}
|
||||
next = page.Page.Next
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("cannot list all ramp accounts: %w", ErrPaginationLimitReached)
|
||||
}
|
||||
|
||||
func (d *RampDriver) queryUsers(ctx context.Context, endpoint string) (*rampUsersPage, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot create ramp users request: %w", err)
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
httpResp, err := d.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot execute ramp users request: %w", err)
|
||||
}
|
||||
defer func() { _ = httpResp.Body.Close() }()
|
||||
|
||||
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("cannot fetch ramp users: unexpected status %d", httpResp.StatusCode)
|
||||
}
|
||||
|
||||
var page rampUsersPage
|
||||
if err := json.NewDecoder(httpResp.Body).Decode(&page); err != nil {
|
||||
return nil, fmt.Errorf("cannot decode ramp users response: %w", err)
|
||||
}
|
||||
|
||||
return &page, nil
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package drivers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestRampDriver(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rec := newRecorder(t, "testdata/ramp", "RAMP_TOKEN")
|
||||
client := newVCRClient(rec, bearerAuth(os.Getenv("RAMP_TOKEN")))
|
||||
|
||||
driver := NewRampDriver(client)
|
||||
records, err := driver.ListAccounts(context.Background())
|
||||
require.NoError(t, err)
|
||||
require.Len(t, records, 2)
|
||||
|
||||
r := records[0]
|
||||
assert.Equal(t, "user-1", r.ExternalID)
|
||||
assert.Equal(t, "jane@example.com", r.Email)
|
||||
assert.Equal(t, "Jane Doe", r.FullName)
|
||||
assert.Equal(t, "BUSINESS_ADMIN", r.Role)
|
||||
require.NotNil(t, r.Active)
|
||||
assert.True(t, *r.Active)
|
||||
assert.True(t, r.IsAdmin)
|
||||
require.NotNil(t, r.LastLogin)
|
||||
|
||||
// Suspended record should be Active=false.
|
||||
require.NotNil(t, records[1].Active)
|
||||
assert.False(t, *records[1].Active)
|
||||
}
|
||||
@@ -1,145 +0,0 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package drivers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"go.probo.inc/probo/pkg/coredata"
|
||||
)
|
||||
|
||||
// SnykDriver fetches organization memberships from the Snyk REST API
|
||||
// using a pre-authenticated HTTP client (Bearer token from the Snyk
|
||||
// Apps OAuth PKCE flow). Pagination is via the `links.next` field on
|
||||
// the response body (a relative URL fragment under api.snyk.io).
|
||||
//
|
||||
// Note: Snyk uses a single-use rotating refresh token (~180d TTL).
|
||||
// Persistence of the rotated refresh token is handled by the existing
|
||||
// callers — see pkg/accessreview/access_source_service.go:336-347 for
|
||||
// the campaign-fetch path and pkg/accessreview/source_name_worker.go:121-128
|
||||
// for the source-name path. Both run inside a transaction so concurrent
|
||||
// runs serialise per-row.
|
||||
type SnykDriver struct {
|
||||
httpClient *http.Client
|
||||
orgID string
|
||||
}
|
||||
|
||||
var _ Driver = (*SnykDriver)(nil)
|
||||
|
||||
func NewSnykDriver(httpClient *http.Client, orgID string) *SnykDriver {
|
||||
return &SnykDriver{
|
||||
httpClient: &http.Client{
|
||||
Transport: &retryRoundTripper{
|
||||
next: httpClient.Transport,
|
||||
maxRetries: 3,
|
||||
},
|
||||
},
|
||||
orgID: orgID,
|
||||
}
|
||||
}
|
||||
|
||||
type snykMembership struct {
|
||||
ID string `json:"id"`
|
||||
Attributes struct {
|
||||
User struct {
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name"`
|
||||
} `json:"user"`
|
||||
Role struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"role"`
|
||||
} `json:"attributes"`
|
||||
}
|
||||
|
||||
type snykMembershipsPage struct {
|
||||
Data []snykMembership `json:"data"`
|
||||
Links struct {
|
||||
Next string `json:"next"`
|
||||
} `json:"links"`
|
||||
}
|
||||
|
||||
func (d *SnykDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error) {
|
||||
var records []AccountRecord
|
||||
|
||||
next := fmt.Sprintf(
|
||||
"https://api.snyk.io/rest/orgs/%s/memberships?version=2024-10-15&limit=100",
|
||||
url.PathEscape(d.orgID),
|
||||
)
|
||||
|
||||
for range maxPaginationPages {
|
||||
page, err := d.queryMemberships(ctx, next)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, m := range page.Data {
|
||||
record := AccountRecord{
|
||||
Email: m.Attributes.User.Email,
|
||||
FullName: m.Attributes.User.Name,
|
||||
Role: m.Attributes.Role.Name,
|
||||
ExternalID: m.ID,
|
||||
MFAStatus: coredata.MFAStatusUnknown,
|
||||
AuthMethod: coredata.AccessEntryAuthMethodUnknown,
|
||||
AccountType: coredata.AccessEntryAccountTypeUser,
|
||||
}
|
||||
records = append(records, record)
|
||||
}
|
||||
|
||||
if page.Links.Next == "" {
|
||||
return records, nil
|
||||
}
|
||||
|
||||
// Snyk surfaces `links.next` as either a path-only fragment
|
||||
// (e.g. "/rest/orgs/<id>/memberships?...&starting_after=...")
|
||||
// or an absolute URL. Normalise to absolute.
|
||||
if strings.HasPrefix(page.Links.Next, "http://") || strings.HasPrefix(page.Links.Next, "https://") {
|
||||
next = page.Links.Next
|
||||
} else {
|
||||
next = "https://api.snyk.io" + page.Links.Next
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("cannot list all snyk accounts: %w", ErrPaginationLimitReached)
|
||||
}
|
||||
|
||||
func (d *SnykDriver) queryMemberships(ctx context.Context, endpoint string) (*snykMembershipsPage, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot create snyk memberships request: %w", err)
|
||||
}
|
||||
req.Header.Set("Accept", "application/vnd.api+json")
|
||||
|
||||
httpResp, err := d.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot execute snyk memberships request: %w", err)
|
||||
}
|
||||
defer func() { _ = httpResp.Body.Close() }()
|
||||
|
||||
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("cannot fetch snyk memberships: unexpected status %d", httpResp.StatusCode)
|
||||
}
|
||||
|
||||
var page snykMembershipsPage
|
||||
if err := json.NewDecoder(httpResp.Body).Decode(&page); err != nil {
|
||||
return nil, fmt.Errorf("cannot decode snyk memberships response: %w", err)
|
||||
}
|
||||
|
||||
return &page, nil
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package drivers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestSnykDriver(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rec := newRecorder(t, "testdata/snyk", "SNYK_TOKEN")
|
||||
client := newVCRClient(rec, bearerAuth(os.Getenv("SNYK_TOKEN")))
|
||||
|
||||
orgID := os.Getenv("SNYK_ORG_ID")
|
||||
if orgID == "" {
|
||||
orgID = "org-1234"
|
||||
}
|
||||
|
||||
driver := NewSnykDriver(client, orgID)
|
||||
records, err := driver.ListAccounts(context.Background())
|
||||
require.NoError(t, err)
|
||||
require.Len(t, records, 2)
|
||||
|
||||
r := records[0]
|
||||
assert.Equal(t, "membership-1", r.ExternalID)
|
||||
assert.Equal(t, "jane@example.com", r.Email)
|
||||
assert.Equal(t, "Jane Doe", r.FullName)
|
||||
assert.Equal(t, "Admin", r.Role)
|
||||
}
|
||||
67
pkg/accessreview/drivers/testdata/deel.yaml
vendored
67
pkg/accessreview/drivers/testdata/deel.yaml
vendored
@@ -1,67 +0,0 @@
|
||||
---
|
||||
version: 2
|
||||
interactions:
|
||||
- id: 0
|
||||
request:
|
||||
proto: HTTP/1.1
|
||||
proto_major: 1
|
||||
proto_minor: 1
|
||||
content_length: 0
|
||||
host: api.letsdeel.com
|
||||
form:
|
||||
limit:
|
||||
- "100"
|
||||
offset:
|
||||
- "0"
|
||||
headers:
|
||||
Accept:
|
||||
- application/json
|
||||
url: https://api.letsdeel.com/rest/v2/people?limit=100&offset=0
|
||||
method: GET
|
||||
response:
|
||||
proto: HTTP/2.0
|
||||
proto_major: 2
|
||||
proto_minor: 0
|
||||
content_length: -1
|
||||
uncompressed: true
|
||||
body: '{"data":[{"id":"d_jane","email":"jane@example.com","first_name":"Jane","last_name":"Doe","job_title":"Engineering Lead","hiring_status":"active","start_date":"2024-06-01","end_date":""},{"id":"d_bob","email":"bob@example.com","first_name":"Bob","last_name":"Smith","job_title":"Designer","hiring_status":"inactive","start_date":"2023-01-15","end_date":"2025-09-30"}]}'
|
||||
headers:
|
||||
Content-Type:
|
||||
- application/json
|
||||
Date:
|
||||
- Thu, 01 May 2026 12:00:00 GMT
|
||||
status: 200 OK
|
||||
code: 200
|
||||
duration: 100ms
|
||||
- id: 1
|
||||
request:
|
||||
proto: HTTP/1.1
|
||||
proto_major: 1
|
||||
proto_minor: 1
|
||||
content_length: 0
|
||||
host: api.letsdeel.com
|
||||
form:
|
||||
limit:
|
||||
- "100"
|
||||
offset:
|
||||
- "100"
|
||||
headers:
|
||||
Accept:
|
||||
- application/json
|
||||
url: https://api.letsdeel.com/rest/v2/people?limit=100&offset=100
|
||||
method: GET
|
||||
response:
|
||||
proto: HTTP/2.0
|
||||
proto_major: 2
|
||||
proto_minor: 0
|
||||
content_length: -1
|
||||
uncompressed: true
|
||||
body: '{"data":[]}'
|
||||
headers:
|
||||
Content-Type:
|
||||
- application/json
|
||||
Date:
|
||||
- Thu, 01 May 2026 12:00:00 GMT
|
||||
status: 200 OK
|
||||
code: 200
|
||||
duration: 100ms
|
||||
33
pkg/accessreview/drivers/testdata/lever.yaml
vendored
33
pkg/accessreview/drivers/testdata/lever.yaml
vendored
@@ -1,33 +0,0 @@
|
||||
---
|
||||
version: 2
|
||||
interactions:
|
||||
- id: 0
|
||||
request:
|
||||
proto: HTTP/1.1
|
||||
proto_major: 1
|
||||
proto_minor: 1
|
||||
content_length: 0
|
||||
host: api.lever.co
|
||||
form:
|
||||
limit:
|
||||
- "100"
|
||||
headers:
|
||||
Accept:
|
||||
- application/json
|
||||
url: https://api.lever.co/v1/users?limit=100
|
||||
method: GET
|
||||
response:
|
||||
proto: HTTP/2.0
|
||||
proto_major: 2
|
||||
proto_minor: 0
|
||||
content_length: -1
|
||||
uncompressed: true
|
||||
body: '{"data":[{"id":"l_jane","email":"jane@example.com","name":"Jane Doe","accessRole":"super admin","deactivatedAt":null,"lastLoggedInAt":1745000000000,"createdAt":1717200000000},{"id":"l_bob","email":"bob@example.com","name":"Bob Smith","accessRole":"admin","deactivatedAt":1735000000000,"lastLoggedInAt":null,"createdAt":1717200000000}],"hasNext":false,"next":""}'
|
||||
headers:
|
||||
Content-Type:
|
||||
- application/json
|
||||
Date:
|
||||
- Thu, 01 May 2026 12:00:00 GMT
|
||||
status: 200 OK
|
||||
code: 200
|
||||
duration: 100ms
|
||||
33
pkg/accessreview/drivers/testdata/ramp.yaml
vendored
33
pkg/accessreview/drivers/testdata/ramp.yaml
vendored
@@ -1,33 +0,0 @@
|
||||
---
|
||||
version: 2
|
||||
interactions:
|
||||
- id: 0
|
||||
request:
|
||||
proto: HTTP/1.1
|
||||
proto_major: 1
|
||||
proto_minor: 1
|
||||
content_length: 0
|
||||
host: api.ramp.com
|
||||
form:
|
||||
page_size:
|
||||
- "100"
|
||||
headers:
|
||||
Accept:
|
||||
- application/json
|
||||
url: https://api.ramp.com/developer/v1/users?page_size=100
|
||||
method: GET
|
||||
response:
|
||||
proto: HTTP/2.0
|
||||
proto_major: 2
|
||||
proto_minor: 0
|
||||
content_length: -1
|
||||
uncompressed: true
|
||||
body: '{"data":[{"id":"user-1","email":"jane@example.com","first_name":"Jane","last_name":"Doe","role":"BUSINESS_ADMIN","status":"USER_ACTIVE","last_login_at":"2026-04-15T10:00:00Z","is_manager":true},{"id":"user-2","email":"bob@example.com","first_name":"Bob","last_name":"Smith","role":"BUSINESS_USER","status":"USER_SUSPENDED","last_login_at":"","is_manager":false}],"page":{"next":""}}'
|
||||
headers:
|
||||
Content-Type:
|
||||
- application/json
|
||||
Date:
|
||||
- Thu, 01 May 2026 12:00:00 GMT
|
||||
status: 200 OK
|
||||
code: 200
|
||||
duration: 100ms
|
||||
35
pkg/accessreview/drivers/testdata/snyk.yaml
vendored
35
pkg/accessreview/drivers/testdata/snyk.yaml
vendored
@@ -1,35 +0,0 @@
|
||||
---
|
||||
version: 2
|
||||
interactions:
|
||||
- id: 0
|
||||
request:
|
||||
proto: HTTP/1.1
|
||||
proto_major: 1
|
||||
proto_minor: 1
|
||||
content_length: 0
|
||||
host: api.snyk.io
|
||||
form:
|
||||
limit:
|
||||
- "100"
|
||||
version:
|
||||
- "2024-10-15"
|
||||
headers:
|
||||
Accept:
|
||||
- application/vnd.api+json
|
||||
url: https://api.snyk.io/rest/orgs/org-1234/memberships?version=2024-10-15&limit=100
|
||||
method: GET
|
||||
response:
|
||||
proto: HTTP/2.0
|
||||
proto_major: 2
|
||||
proto_minor: 0
|
||||
content_length: -1
|
||||
uncompressed: true
|
||||
body: '{"data":[{"id":"membership-1","type":"org_membership","attributes":{"user":{"id":"user-1","email":"jane@example.com","name":"Jane Doe"},"role":{"id":"role-1","name":"Admin"}}},{"id":"membership-2","type":"org_membership","attributes":{"user":{"id":"user-2","email":"bob@example.com","name":"Bob Smith"},"role":{"id":"role-2","name":"Collaborator"}}}],"links":{"self":"/rest/orgs/org-1234/memberships?version=2024-10-15&limit=100"}}'
|
||||
headers:
|
||||
Content-Type:
|
||||
- application/vnd.api+json
|
||||
Date:
|
||||
- Thu, 01 May 2026 12:00:00 GMT
|
||||
status: 200 OK
|
||||
code: 200
|
||||
duration: 100ms
|
||||
@@ -423,15 +423,6 @@ func (e *ReviewEngine) resolveDriver(
|
||||
return nil, fmt.Errorf("asana connector requires workspace_gid in settings")
|
||||
}
|
||||
return drivers.NewAsanaDriver(httpClient, asanaSettings.WorkspaceGID), nil
|
||||
case coredata.ConnectorProviderSnyk:
|
||||
snykSettings, err := coredata.ConnectorSettings[coredata.SnykConnectorSettings](dbConnector)
|
||||
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 := coredata.ConnectorSettings[coredata.NetlifyConnectorSettings](dbConnector)
|
||||
if err != nil {
|
||||
@@ -441,8 +432,6 @@ func (e *ReviewEngine) resolveDriver(
|
||||
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 := coredata.ConnectorSettings[coredata.ClickUpConnectorSettings](dbConnector)
|
||||
if err != nil {
|
||||
@@ -463,10 +452,6 @@ func (e *ReviewEngine) resolveDriver(
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -318,13 +318,6 @@ func (h *sourceNameHandler) buildResolver(
|
||||
return nil
|
||||
}
|
||||
return drivers.NewAsanaNameResolver(httpClient, asanaSettings.WorkspaceGID)
|
||||
case coredata.ConnectorProviderSnyk:
|
||||
snykSettings, err := coredata.ConnectorSettings[coredata.SnykConnectorSettings](dbConnector)
|
||||
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 := coredata.ConnectorSettings[coredata.NetlifyConnectorSettings](dbConnector)
|
||||
if err != nil {
|
||||
@@ -332,8 +325,6 @@ func (h *sourceNameHandler) buildResolver(
|
||||
return nil
|
||||
}
|
||||
return drivers.NewNetlifyNameResolver(httpClient, netlifySettings.AccountSlug)
|
||||
case coredata.ConnectorProviderRamp:
|
||||
return drivers.NewRampNameResolver(httpClient)
|
||||
case coredata.ConnectorProviderClickUp:
|
||||
clickupSettings, err := coredata.ConnectorSettings[coredata.ClickUpConnectorSettings](dbConnector)
|
||||
if err != nil {
|
||||
@@ -350,10 +341,6 @@ func (h *sourceNameHandler) buildResolver(
|
||||
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
|
||||
}
|
||||
|
||||
@@ -355,13 +355,9 @@ func (b *Builder) Build() (*probodconfig.FullConfig, error) {
|
||||
"HEROKU",
|
||||
"PAGERDUTY",
|
||||
"ASANA",
|
||||
"SNYK",
|
||||
"NETLIFY",
|
||||
"RAMP",
|
||||
"CLICKUP",
|
||||
"MONDAY",
|
||||
"LEVER",
|
||||
"DEEL",
|
||||
} {
|
||||
clientID := b.getEnv("CONNECTOR_" + provider + "_CLIENT_ID")
|
||||
if clientID == "" {
|
||||
@@ -438,6 +434,15 @@ func (b *Builder) validateRequired() error {
|
||||
{"CONNECTOR_BREX", []string{"CLIENT_SECRET"}},
|
||||
{"CONNECTOR_GOOGLE_WORKSPACE", []string{"CLIENT_SECRET"}},
|
||||
{"CONNECTOR_MICROSOFT_365", []string{"CLIENT_SECRET"}},
|
||||
{"CONNECTOR_GITLAB", []string{"CLIENT_SECRET"}},
|
||||
{"CONNECTOR_BITBUCKET", []string{"CLIENT_SECRET"}},
|
||||
{"CONNECTOR_HEROKU", []string{"CLIENT_SECRET"}},
|
||||
{"CONNECTOR_PAGERDUTY", []string{"CLIENT_SECRET"}},
|
||||
{"CONNECTOR_ASANA", []string{"CLIENT_SECRET"}},
|
||||
{"CONNECTOR_NETLIFY", []string{"CLIENT_SECRET"}},
|
||||
{"CONNECTOR_CLICKUP", []string{"CLIENT_SECRET"}},
|
||||
{"CONNECTOR_MONDAY", []string{"CLIENT_SECRET"}},
|
||||
{"CONNECTOR_VERCEL", []string{"CLIENT_SECRET", "INTEGRATION_SLUG"}},
|
||||
}
|
||||
|
||||
for _, p := range oauthProviders {
|
||||
|
||||
@@ -442,8 +442,7 @@ func TestBuilder_Build_AccessReviewConnectors(t *testing.T) {
|
||||
// CONNECTOR_VERCEL_INTEGRATION_SLUG env var.
|
||||
providers := []string{
|
||||
"GITLAB", "BITBUCKET", "HEROKU", "PAGERDUTY",
|
||||
"ASANA", "SNYK", "NETLIFY", "RAMP", "CLICKUP",
|
||||
"MONDAY", "LEVER", "DEEL",
|
||||
"ASANA", "NETLIFY", "CLICKUP", "MONDAY",
|
||||
}
|
||||
|
||||
env := requiredEnv()
|
||||
|
||||
@@ -882,12 +882,12 @@ func TestGeneratePKCEVerifier(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestApplyProviderDefaults_PKCEDefaults asserts that the registered
|
||||
// PAGERDUTY and SNYK provider defaults flip RequiresPKCE on so the
|
||||
// downstream Initiate/Complete flow generates a verifier and replays it.
|
||||
// PAGERDUTY provider defaults flip RequiresPKCE on so the downstream
|
||||
// Initiate/Complete flow generates a verifier and replays it.
|
||||
func TestApplyProviderDefaults_PKCEDefaults(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, provider := range []string{"PAGERDUTY", "SNYK"} {
|
||||
for _, provider := range []string{"PAGERDUTY"} {
|
||||
t.Run(provider, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -899,31 +899,6 @@ func TestApplyProviderDefaults_PKCEDefaults(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyProviderDefaults_TokenExtraParamsDeepCopy guards against the
|
||||
// shared-map aliasing bug class. Two connectors using the same provider
|
||||
// (LEVER carries a non-empty TokenExtraParams) must not share the
|
||||
// underlying map; mutating one must not be observable on the other or
|
||||
// in the package-level providerDefinitions.
|
||||
func TestApplyProviderDefaults_TokenExtraParamsDeepCopy(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
c1 := &OAuth2Connector{ClientID: "id1", ClientSecret: "s1"}
|
||||
c2 := &OAuth2Connector{ClientID: "id2", ClientSecret: "s2"}
|
||||
|
||||
ApplyProviderDefaults("LEVER", "https://example.com/cb", c1)
|
||||
ApplyProviderDefaults("LEVER", "https://example.com/cb", c2)
|
||||
|
||||
require.NotNil(t, c1.TokenExtraParams)
|
||||
require.NotNil(t, c2.TokenExtraParams)
|
||||
require.Equal(t, "https://api.lever.co/v1/", c1.TokenExtraParams["audience"])
|
||||
|
||||
c1.TokenExtraParams["sentinel"] = "mutated"
|
||||
assert.NotContains(t, c2.TokenExtraParams, "sentinel",
|
||||
"second connector must not see mutations on the first")
|
||||
assert.NotContains(t, providerDefinitions["LEVER"].TokenExtraParams, "sentinel",
|
||||
"shared providerDefinitions map must remain pristine")
|
||||
}
|
||||
|
||||
// TestCompleteWithState_PKCEMismatch confirms that a token endpoint
|
||||
// rejecting a stale or mismatched code_verifier (the standard PKCE
|
||||
// failure path) surfaces as an error from CompleteWithState rather
|
||||
|
||||
@@ -128,20 +128,10 @@ var (
|
||||
AuthURL: "https://app.asana.com/-/oauth_authorize",
|
||||
TokenURL: "https://app.asana.com/-/oauth_token",
|
||||
},
|
||||
"SNYK": {
|
||||
AuthURL: "https://app.snyk.io/oauth2/authorize",
|
||||
TokenURL: "https://api.snyk.io/oauth2/token",
|
||||
RequiresPKCE: true,
|
||||
},
|
||||
"NETLIFY": {
|
||||
AuthURL: "https://app.netlify.com/authorize",
|
||||
TokenURL: "https://api.netlify.com/oauth/token",
|
||||
},
|
||||
"RAMP": {
|
||||
AuthURL: "https://app.ramp.com/v1/authorize",
|
||||
TokenURL: "https://api.ramp.com/developer/v1/token",
|
||||
TokenEndpointAuth: "basic-form",
|
||||
},
|
||||
"CLICKUP": {
|
||||
AuthURL: "https://app.clickup.com/api",
|
||||
TokenURL: "https://api.clickup.com/api/v2/oauth/token",
|
||||
@@ -159,31 +149,6 @@ var (
|
||||
AuthURL: "https://auth.monday.com/oauth2/authorize",
|
||||
TokenURL: "https://auth.monday.com/oauth2/token",
|
||||
},
|
||||
// Lever runs on Auth0: the `audience` parameter is required in
|
||||
// BOTH the authorize URL and the token-exchange POST body. The
|
||||
// trailing slash on the audience value is mandatory.
|
||||
"LEVER": {
|
||||
AuthURL: "https://auth.lever.co/authorize",
|
||||
TokenURL: "https://auth.lever.co/oauth/token",
|
||||
ExtraAuthParams: map[string]string{
|
||||
"audience": "https://api.lever.co/v1/",
|
||||
"prompt": "consent",
|
||||
},
|
||||
TokenExtraParams: map[string]string{
|
||||
"audience": "https://api.lever.co/v1/",
|
||||
},
|
||||
},
|
||||
// Deel: the token endpoint path is "/oauth2/tokens" (plural) —
|
||||
// Deel's docs are inconsistent on the singular vs plural form.
|
||||
// The API base host (api.letsdeel.com) differs from the auth host
|
||||
// (app.deel.com). Deel's token endpoint requires HTTP Basic auth
|
||||
// (base64(client_id:client_secret)); credentials placed in the
|
||||
// form body are rejected with 401 invalid basic credentials.
|
||||
"DEEL": {
|
||||
AuthURL: "https://app.deel.com/oauth2/authorize",
|
||||
TokenURL: "https://app.deel.com/oauth2/tokens",
|
||||
TokenEndpointAuth: "basic-form",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -142,17 +142,13 @@ var (
|
||||
"HEROKU": "https://api.heroku.com/account",
|
||||
"PAGERDUTY": "https://api.pagerduty.com/users/me",
|
||||
"ASANA": "https://app.asana.com/api/1.0/users/me",
|
||||
"SNYK": "https://api.snyk.io/rest/self?version=2024-10-15",
|
||||
"NETLIFY": "https://api.netlify.com/api/v1/user",
|
||||
"RAMP": "https://api.ramp.com/developer/v1/business",
|
||||
"CLICKUP": "https://api.clickup.com/api/v2/user",
|
||||
"VERCEL": "https://api.vercel.com/v2/user",
|
||||
// Monday's primary API is GraphQL POST, but the probe handler
|
||||
// is GET-only. Use the OIDC userinfo endpoint as a GET probe
|
||||
// that returns 200/401 with the same Bearer token.
|
||||
"MONDAY": "https://auth.monday.com/oauth2/userinfo",
|
||||
"LEVER": "https://api.lever.co/v1/users?limit=1",
|
||||
"DEEL": "https://api.letsdeel.com/rest/v2/people?limit=1",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -45,14 +45,10 @@ const (
|
||||
ConnectorProviderHeroku ConnectorProvider = "HEROKU"
|
||||
ConnectorProviderPagerDuty ConnectorProvider = "PAGERDUTY"
|
||||
ConnectorProviderAsana ConnectorProvider = "ASANA"
|
||||
ConnectorProviderSnyk ConnectorProvider = "SNYK"
|
||||
ConnectorProviderNetlify ConnectorProvider = "NETLIFY"
|
||||
ConnectorProviderRamp ConnectorProvider = "RAMP"
|
||||
ConnectorProviderClickUp ConnectorProvider = "CLICKUP"
|
||||
ConnectorProviderVercel ConnectorProvider = "VERCEL"
|
||||
ConnectorProviderMonday ConnectorProvider = "MONDAY"
|
||||
ConnectorProviderLever ConnectorProvider = "LEVER"
|
||||
ConnectorProviderDeel ConnectorProvider = "DEEL"
|
||||
)
|
||||
|
||||
func ConnectorProviders() []ConnectorProvider {
|
||||
@@ -79,14 +75,10 @@ func ConnectorProviders() []ConnectorProvider {
|
||||
ConnectorProviderHeroku,
|
||||
ConnectorProviderPagerDuty,
|
||||
ConnectorProviderAsana,
|
||||
ConnectorProviderSnyk,
|
||||
ConnectorProviderNetlify,
|
||||
ConnectorProviderRamp,
|
||||
ConnectorProviderClickUp,
|
||||
ConnectorProviderVercel,
|
||||
ConnectorProviderMonday,
|
||||
ConnectorProviderLever,
|
||||
ConnectorProviderDeel,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,22 +142,14 @@ func (cp *ConnectorProvider) Scan(value any) error {
|
||||
*cp = ConnectorProviderPagerDuty
|
||||
case "ASANA":
|
||||
*cp = ConnectorProviderAsana
|
||||
case "SNYK":
|
||||
*cp = ConnectorProviderSnyk
|
||||
case "NETLIFY":
|
||||
*cp = ConnectorProviderNetlify
|
||||
case "RAMP":
|
||||
*cp = ConnectorProviderRamp
|
||||
case "CLICKUP":
|
||||
*cp = ConnectorProviderClickUp
|
||||
case "VERCEL":
|
||||
*cp = ConnectorProviderVercel
|
||||
case "MONDAY":
|
||||
*cp = ConnectorProviderMonday
|
||||
case "LEVER":
|
||||
*cp = ConnectorProviderLever
|
||||
case "DEEL":
|
||||
*cp = ConnectorProviderDeel
|
||||
default:
|
||||
return fmt.Errorf("invalid ConnectorProvider value: %q", s)
|
||||
}
|
||||
|
||||
@@ -70,10 +70,6 @@ type (
|
||||
WorkspaceGID string `json:"workspace_gid"`
|
||||
}
|
||||
|
||||
SnykConnectorSettings struct {
|
||||
OrgID string `json:"org_id"`
|
||||
}
|
||||
|
||||
NetlifyConnectorSettings struct {
|
||||
AccountSlug string `json:"account_slug"`
|
||||
}
|
||||
|
||||
@@ -17,11 +17,7 @@ ALTER TYPE connector_provider ADD VALUE IF NOT EXISTS 'BITBUCKET';
|
||||
ALTER TYPE connector_provider ADD VALUE IF NOT EXISTS 'HEROKU';
|
||||
ALTER TYPE connector_provider ADD VALUE IF NOT EXISTS 'PAGERDUTY';
|
||||
ALTER TYPE connector_provider ADD VALUE IF NOT EXISTS 'ASANA';
|
||||
ALTER TYPE connector_provider ADD VALUE IF NOT EXISTS 'SNYK';
|
||||
ALTER TYPE connector_provider ADD VALUE IF NOT EXISTS 'NETLIFY';
|
||||
ALTER TYPE connector_provider ADD VALUE IF NOT EXISTS 'RAMP';
|
||||
ALTER TYPE connector_provider ADD VALUE IF NOT EXISTS 'CLICKUP';
|
||||
ALTER TYPE connector_provider ADD VALUE IF NOT EXISTS 'VERCEL';
|
||||
ALTER TYPE connector_provider ADD VALUE IF NOT EXISTS 'MONDAY';
|
||||
ALTER TYPE connector_provider ADD VALUE IF NOT EXISTS 'LEVER';
|
||||
ALTER TYPE connector_provider ADD VALUE IF NOT EXISTS 'DEEL';
|
||||
|
||||
@@ -92,14 +92,6 @@ var providerOrgConfigs = map[coredata.ConnectorProvider]providerOrgConfig{
|
||||
},
|
||||
NeedsPicker: true,
|
||||
},
|
||||
coredata.ConnectorProviderSnyk: {
|
||||
ListOrgs: drivers.ListSnykOrganizations,
|
||||
SelectedSlug: func(c *coredata.Connector) string {
|
||||
s, _ := coredata.ConnectorSettings[coredata.SnykConnectorSettings](c)
|
||||
return s.OrgID
|
||||
},
|
||||
NeedsPicker: true,
|
||||
},
|
||||
coredata.ConnectorProviderNetlify: {
|
||||
ListOrgs: drivers.ListNetlifyOrganizations,
|
||||
SelectedSlug: func(c *coredata.Connector) string {
|
||||
|
||||
@@ -38,16 +38,12 @@ enum ConnectorProvider
|
||||
PAGERDUTY
|
||||
@goEnum(value: "go.probo.inc/probo/pkg/coredata.ConnectorProviderPagerDuty")
|
||||
ASANA @goEnum(value: "go.probo.inc/probo/pkg/coredata.ConnectorProviderAsana")
|
||||
SNYK @goEnum(value: "go.probo.inc/probo/pkg/coredata.ConnectorProviderSnyk")
|
||||
NETLIFY
|
||||
@goEnum(value: "go.probo.inc/probo/pkg/coredata.ConnectorProviderNetlify")
|
||||
RAMP @goEnum(value: "go.probo.inc/probo/pkg/coredata.ConnectorProviderRamp")
|
||||
CLICKUP
|
||||
@goEnum(value: "go.probo.inc/probo/pkg/coredata.ConnectorProviderClickUp")
|
||||
VERCEL @goEnum(value: "go.probo.inc/probo/pkg/coredata.ConnectorProviderVercel")
|
||||
MONDAY @goEnum(value: "go.probo.inc/probo/pkg/coredata.ConnectorProviderMonday")
|
||||
LEVER @goEnum(value: "go.probo.inc/probo/pkg/coredata.ConnectorProviderLever")
|
||||
DEEL @goEnum(value: "go.probo.inc/probo/pkg/coredata.ConnectorProviderDeel")
|
||||
}
|
||||
|
||||
type ConnectorProviderInfo {
|
||||
|
||||
Reference in New Issue
Block a user