diff --git a/apps/console/src/pages/organizations/access-reviews/dialogs/AddAccessReviewSourceDialog.tsx b/apps/console/src/pages/organizations/access-reviews/dialogs/AddAccessReviewSourceDialog.tsx index 5ce7f3238..279025332 100644 --- a/apps/console/src/pages/organizations/access-reviews/dialogs/AddAccessReviewSourceDialog.tsx +++ b/apps/console/src/pages/organizations/access-reviews/dialogs/AddAccessReviewSourceDialog.tsx @@ -160,6 +160,12 @@ function mapAPIKeyExtraSettingToField( case "NEON": if (settingKey === "organizationId") return "neonOrganizationId"; break; + case "SCALEWAY": + if (settingKey === "organizationId") return "scalewayOrganizationId"; + break; + case "CRISP": + if (settingKey === "websiteId") return "crispWebsiteId"; + break; } return null; } diff --git a/pkg/accessreview/drivers/crisp.go b/pkg/accessreview/drivers/crisp.go new file mode 100644 index 000000000..ddd0851b2 --- /dev/null +++ b/pkg/accessreview/drivers/crisp.go @@ -0,0 +1,151 @@ +// 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" + "strings" + + "go.probo.inc/probo/pkg/coredata" +) + +const ( + crispAPIBaseURL = "https://api.crisp.chat/v1" + // crispTierHeader selects the token tier on every Crisp request. A Probo + // connection uses a plugin token, so the value is always "plugin". This is + // not authentication (the Basic credential is attached by the transport), + // so the driver, probe and name resolver each set it explicitly. + crispTierHeader = "X-Crisp-Tier" + crispTierValue = "plugin" +) + +// CrispDriver lists the operators (dashboard agents) of a single Crisp website. +// A plugin token can be connected to several websites, so the website is +// captured up front as a connector setting; the Basic credential +// (identifier:key) is applied by the connection transport. +type CrispDriver struct { + httpClient *http.Client + websiteID string +} + +var _ Driver = (*CrispDriver)(nil) + +type crispOperatorsResponse struct { + Data []struct { + Details crispOperatorDetails `json:"details"` + } `json:"data"` +} + +type crispOperatorDetails struct { + UserID string `json:"user_id"` + Email string `json:"email"` + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + Role string `json:"role"` + Title string `json:"title"` +} + +func NewCrispDriver(httpClient *http.Client, websiteID string) *CrispDriver { + return &CrispDriver{ + httpClient: httpClient, + websiteID: websiteID, + } +} + +func (d *CrispDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error) { + endpoint, err := url.JoinPath(crispAPIBaseURL, "website", url.PathEscape(d.websiteID), "operators", "list") + if err != nil { + return nil, fmt.Errorf("cannot build crisp operators URL: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, fmt.Errorf("cannot create crisp operators request: %w", err) + } + + req.Header.Set("Accept", "application/json") + req.Header.Set(crispTierHeader, crispTierValue) + + httpResp, err := d.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("cannot execute crisp operators request: %w", err) + } + + defer func() { _ = httpResp.Body.Close() }() + + if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 { + return nil, fmt.Errorf("cannot fetch crisp operators: unexpected status %d", httpResp.StatusCode) + } + + var resp crispOperatorsResponse + if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil { + return nil, fmt.Errorf("cannot decode crisp operators response: %w", err) + } + + records := make([]AccountRecord, 0, len(resp.Data)) + + for _, op := range resp.Data { + details := op.Details + + email := strings.TrimSpace(details.Email) + if email == "" { + continue + } + + records = append(records, AccountRecord{ + Email: email, + FullName: crispFullName(details, email), + Roles: crispRoles(details.Role), + JobTitle: strings.TrimSpace(details.Title), + IsAdmin: strings.EqualFold(strings.TrimSpace(details.Role), "owner"), + MFAStatus: coredata.MFAStatusUnknown, + AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown, + AccountType: coredata.AccessReviewEntryAccountTypeUser, + ExternalID: strings.TrimSpace(details.UserID), + }) + } + + return records, nil +} + +func crispFullName(details crispOperatorDetails, fallback string) string { + if name := strings.TrimSpace(details.FirstName + " " + details.LastName); name != "" { + return name + } + + return fallback +} + +// crispRoles maps a Crisp operator role to a display label. Documented roles +// are owner/member; an unknown future value is passed through verbatim and no +// role yields an empty slice. +func crispRoles(role string) []string { + switch strings.ToLower(strings.TrimSpace(role)) { + case "owner": + return []string{"Owner"} + case "member": + return []string{"Member"} + default: + if r := strings.TrimSpace(role); r != "" { + return []string{r} + } + + return []string{} + } +} diff --git a/pkg/accessreview/drivers/crisp_test.go b/pkg/accessreview/drivers/crisp_test.go new file mode 100644 index 000000000..2ae0c63db --- /dev/null +++ b/pkg/accessreview/drivers/crisp_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" + "go.probo.inc/probo/pkg/coredata" +) + +func TestCrispDriver(t *testing.T) { + t.Parallel() + + rec := newRecorder(t, "testdata/crisp", "CRISP_API_KEY") + // Crisp authenticates with HTTP Basic over the "identifier:key" plugin + // token. The matcher ignores Authorization, so replay needs no credential. + client := newVCRClient(rec, basicAuthUserPass(os.Getenv("CRISP_API_KEY"))) + + driver := NewCrispDriver(client, "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d") + records, err := driver.ListAccounts(context.Background()) + require.NoError(t, err) + require.Len(t, records, 2) + + owner := records[0] + assert.Equal(t, "5c068745-c7da-4b59-89a0-1b67f3b0d6df", owner.ExternalID) + assert.Equal(t, "alex@example.com", owner.Email) + assert.Equal(t, "Alex Martin", owner.FullName) + assert.Equal(t, []string{"Owner"}, owner.Roles) + assert.True(t, owner.IsAdmin) + assert.Equal(t, "Founder", owner.JobTitle) + assert.Equal(t, coredata.AccessReviewEntryAccountTypeUser, owner.AccountType) + + member := records[1] + assert.Equal(t, "9a1f3c2e-6b4d-4f8a-bc11-7d2e9f0a1b22", member.ExternalID) + assert.Equal(t, "jordan@example.com", member.Email) + assert.Equal(t, []string{"Member"}, member.Roles) + assert.False(t, member.IsAdmin) + assert.Equal(t, "Support Agent", member.JobTitle) +} diff --git a/pkg/accessreview/drivers/name_resolver.go b/pkg/accessreview/drivers/name_resolver.go index 4ec081eac..e37b0ba85 100644 --- a/pkg/accessreview/drivers/name_resolver.go +++ b/pkg/accessreview/drivers/name_resolver.go @@ -1532,3 +1532,136 @@ func (r *microsoft365NameResolver) ResolveInstanceName(ctx context.Context) (str return "", nil } + +// railwayNameResolver resolves the Railway workspace name via GraphQL, for the +// AccessReviewSource title. With a single workspace it uses that workspace's +// name; with several it falls back to the account holder's name, since the +// source spans all of the account's workspaces. +type railwayNameResolver struct { + httpClient *http.Client +} + +func NewRailwayNameResolver(httpClient *http.Client) NameResolver { + return &railwayNameResolver{httpClient: httpClient} +} + +func (r *railwayNameResolver) ResolveInstanceName(ctx context.Context) (string, error) { + body := struct { + Query string `json:"query"` + }{ + Query: `query { me { name workspaces { id name } } }`, + } + + payload, err := json.Marshal(body) + if err != nil { + return "", fmt.Errorf("cannot marshal railway account query: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, railwayGraphQLEndpoint, bytes.NewReader(payload)) + if err != nil { + return "", fmt.Errorf("cannot create railway account request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + + httpResp, err := r.httpClient.Do(req) + if err != nil { + return "", fmt.Errorf("cannot execute railway account request: %w", err) + } + + defer func() { _ = httpResp.Body.Close() }() + + // Best-effort: a non-2xx must not make the source-name worker retry forever + // — keep the generic name. (Railway also signals auth failure with a 200 + + // errors body, handled below.) + if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 { + return "", nil + } + + var resp struct { + Data struct { + Me *struct { + Name string `json:"name"` + Workspaces []struct { + Name string `json:"name"` + } `json:"workspaces"` + } `json:"me"` + } `json:"data"` + Errors []struct { + Message string `json:"message"` + } `json:"errors"` + } + if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil { + return "", fmt.Errorf("cannot decode railway account response: %w", err) + } + + if len(resp.Errors) > 0 || resp.Data.Me == nil { + return "", nil + } + + // A single workspace names the source directly; with several (or none) fall + // back to the account holder's display name. Never the email — a terminal + // empty result keeps the generic source name, which the worker tolerates. + me := resp.Data.Me + if len(me.Workspaces) == 1 { + return me.Workspaces[0].Name, nil + } + + return me.Name, nil +} + +// crispNameResolver resolves the Crisp website name via GET /v1/website/{id}, +// for the AccessReviewSource title. Like the driver it sends the X-Crisp-Tier +// header; the Basic credential is supplied by the connection transport. +type crispNameResolver struct { + httpClient *http.Client + websiteID string +} + +func NewCrispNameResolver(httpClient *http.Client, websiteID string) NameResolver { + return &crispNameResolver{httpClient: httpClient, websiteID: websiteID} +} + +func (r *crispNameResolver) ResolveInstanceName(ctx context.Context) (string, error) { + if r.websiteID == "" { + return "", nil + } + + endpoint, err := url.JoinPath(crispAPIBaseURL, "website", url.PathEscape(r.websiteID)) + if err != nil { + return "", fmt.Errorf("cannot build crisp website URL: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return "", fmt.Errorf("cannot create crisp website request: %w", err) + } + + req.Header.Set("Accept", "application/json") + req.Header.Set(crispTierHeader, crispTierValue) + + httpResp, err := r.httpClient.Do(req) + if err != nil { + return "", fmt.Errorf("cannot execute crisp website request: %w", err) + } + + defer func() { _ = httpResp.Body.Close() }() + + // Best-effort: a non-2xx (revoked token, stale website id) must not make the + // source-name worker retry forever — keep the generic name. + if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 { + return "", nil + } + + 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 crisp website response: %w", err) + } + + return resp.Data.Name, nil +} diff --git a/pkg/accessreview/drivers/railway.go b/pkg/accessreview/drivers/railway.go new file mode 100644 index 000000000..13092f232 --- /dev/null +++ b/pkg/accessreview/drivers/railway.go @@ -0,0 +1,279 @@ +// 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" + "sort" + "strings" + + "go.probo.inc/probo/pkg/coredata" +) + +// railwayGraphQLEndpoint is Railway's GraphQL API (note the .com TLD — the +// legacy backboard.railway.app host is deprecated). +const railwayGraphQLEndpoint = "https://backboard.railway.com/graphql/v2" + +// railwayMembersQuery fetches the authenticated account and the members of all +// its workspaces. members/workspaces are plain lists (not Relay connections), +// so a single request returns everyone; the same user id recurs across +// workspaces and is deduplicated by the driver. +const railwayMembersQuery = `query { me { id name email workspaces { id name members { id email name role twoFactorAuthEnabled } } } }` + +// RailwayDriver lists the members of every workspace an account token can see, +// via Railway's GraphQL API. The token flows in the Authorization header as a +// Bearer credential set by the connection transport. +type RailwayDriver struct { + httpClient *http.Client +} + +var _ Driver = (*RailwayDriver)(nil) + +func NewRailwayDriver(httpClient *http.Client) *RailwayDriver { + return &RailwayDriver{httpClient: httpClient} +} + +type railwayMember struct { + ID string `json:"id"` + Email string `json:"email"` + Name string `json:"name"` + Role string `json:"role"` + TwoFactorAuthEnabled *bool `json:"twoFactorAuthEnabled"` +} + +type railwayWorkspace struct { + ID string `json:"id"` + Name string `json:"name"` + Members []railwayMember `json:"members"` +} + +type railwayMe struct { + ID string `json:"id"` + Name string `json:"name"` + Email string `json:"email"` + Workspaces []railwayWorkspace `json:"workspaces"` +} + +type railwayMeResponse struct { + Data struct { + Me *railwayMe `json:"me"` + } `json:"data"` + Errors []struct { + Message string `json:"message"` + } `json:"errors"` +} + +// railwayAggregate accumulates a single human's appearances across workspaces: +// roles are unioned, IsAdmin is true if any workspace lists them as ADMIN, and +// MFA is enabled if any workspace reports it (with a separate signal flag so an +// all-null result stays Unknown rather than Disabled). +type railwayAggregate struct { + record AccountRecord + roles map[string]struct{} + isAdmin bool + mfaEnabled bool + mfaSignal bool +} + +func (d *RailwayDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error) { + me, err := d.queryMe(ctx) + if err != nil { + return nil, err + } + + return railwayRecords(me), nil +} + +// railwayRecords aggregates the members of every workspace into one record per +// human, deduplicated by member id: roles are unioned, IsAdmin is true if any +// workspace lists them as ADMIN, and MFA is enabled if any workspace reports it +// (an all-null twoFactorAuthEnabled stays Unknown). +func railwayRecords(me *railwayMe) []AccountRecord { + order := make([]string, 0) + byKey := make(map[string]*railwayAggregate) + + for _, ws := range me.Workspaces { + for _, m := range ws.Members { + email := strings.TrimSpace(m.Email) + if email == "" { + continue + } + + id := strings.TrimSpace(m.ID) + + key := id + if key == "" { + key = email + } + + agg, ok := byKey[key] + if !ok { + agg = &railwayAggregate{ + record: AccountRecord{ + Email: email, + FullName: railwayFullName(m, email), + AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown, + AccountType: coredata.AccessReviewEntryAccountTypeUser, + ExternalID: id, + }, + roles: make(map[string]struct{}), + } + byKey[key] = agg + order = append(order, key) + } + + for _, role := range railwayRoles(m.Role) { + agg.roles[role] = struct{}{} + } + + if strings.EqualFold(strings.TrimSpace(m.Role), "ADMIN") { + agg.isAdmin = true + } + + if m.TwoFactorAuthEnabled != nil { + agg.mfaSignal = true + if *m.TwoFactorAuthEnabled { + agg.mfaEnabled = true + } + } + } + } + + records := make([]AccountRecord, 0, len(order)) + + for _, key := range order { + agg := byKey[key] + + roles := make([]string, 0, len(agg.roles)) + for role := range agg.roles { + roles = append(roles, role) + } + + sort.Strings(roles) + + agg.record.Roles = roles + agg.record.IsAdmin = agg.isAdmin + agg.record.MFAStatus = railwayMFAStatus(agg.mfaSignal, agg.mfaEnabled) + + records = append(records, agg.record) + } + + // Railway does not guarantee a stable member ordering across calls, so sort + // by email (external id as tiebreak) for deterministic output, mirroring the + // per-record role sort above. + sort.Slice(records, func(i, j int) bool { + if records[i].Email != records[j].Email { + return records[i].Email < records[j].Email + } + + return records[i].ExternalID < records[j].ExternalID + }) + + return records +} + +func (d *RailwayDriver) queryMe(ctx context.Context) (*railwayMe, error) { + body := struct { + Query string `json:"query"` + }{ + Query: railwayMembersQuery, + } + + payload, err := json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("cannot marshal railway members query: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, railwayGraphQLEndpoint, bytes.NewReader(payload)) + if err != nil { + return nil, fmt.Errorf("cannot create railway members 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 railway members request: %w", err) + } + + defer func() { _ = httpResp.Body.Close() }() + + if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 { + return nil, fmt.Errorf("cannot fetch railway members: unexpected status %d", httpResp.StatusCode) + } + + var resp railwayMeResponse + if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil { + return nil, fmt.Errorf("cannot decode railway members response: %w", err) + } + + // Railway returns HTTP 200 with a populated errors array (and data.me null) + // for a rejected token, so the status alone cannot be trusted. Provider + // messages may carry identifiers — never embed them in the returned error. + if len(resp.Errors) > 0 { + return nil, fmt.Errorf("cannot fetch railway members: graphql error") + } + + if resp.Data.Me == nil { + return nil, fmt.Errorf("cannot fetch railway members: no authenticated account") + } + + return resp.Data.Me, nil +} + +func railwayFullName(m railwayMember, fallback string) string { + if name := strings.TrimSpace(m.Name); name != "" { + return name + } + + return fallback +} + +// railwayRoles maps Railway's TeamRole enum to a display label. The enum is +// ADMIN/MEMBER/VIEWER; an unknown future value is passed through verbatim and +// no role yields an empty slice. +func railwayRoles(role string) []string { + switch strings.ToUpper(strings.TrimSpace(role)) { + case "ADMIN": + return []string{"Admin"} + case "MEMBER": + return []string{"Member"} + case "VIEWER": + return []string{"Viewer"} + default: + if r := strings.TrimSpace(role); r != "" { + return []string{r} + } + + return []string{} + } +} + +func railwayMFAStatus(hasSignal, enabled bool) coredata.MFAStatus { + if !hasSignal { + return coredata.MFAStatusUnknown + } + + if enabled { + return coredata.MFAStatusEnabled + } + + return coredata.MFAStatusDisabled +} diff --git a/pkg/accessreview/drivers/railway_test.go b/pkg/accessreview/drivers/railway_test.go new file mode 100644 index 000000000..7790497ca --- /dev/null +++ b/pkg/accessreview/drivers/railway_test.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" + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.probo.inc/probo/pkg/coredata" +) + +func TestRailwayDriver(t *testing.T) { + t.Parallel() + + rec := newRecorder(t, "testdata/railway", "RAILWAY_TOKEN") + client := newVCRClient(rec, bearerAuth(os.Getenv("RAILWAY_TOKEN"))) + + driver := NewRailwayDriver(client) + records, err := driver.ListAccounts(context.Background()) + require.NoError(t, err) + require.Len(t, records, 2) + + admin := records[0] + assert.Equal(t, "8f7e6d5c-4b3a-2910-8a7b-6c5d4e3f2a1b", admin.ExternalID) + assert.Equal(t, "ada@example.com", admin.Email) + assert.Equal(t, "Ada Lovelace", admin.FullName) + assert.Equal(t, []string{"Admin"}, admin.Roles) + assert.True(t, admin.IsAdmin) + assert.Equal(t, coredata.MFAStatusEnabled, admin.MFAStatus) + // Railway's WorkspaceMember exposes no status/active field. + assert.Nil(t, admin.Active) + assert.Equal(t, coredata.AccessReviewEntryAccountTypeUser, admin.AccountType) + + member := records[1] + assert.Equal(t, "1b2c3d4e-5f6a-7b8c-9d0e-1f2a3b4c5d6e", member.ExternalID) + assert.Equal(t, "grace@example.com", member.Email) + assert.Equal(t, []string{"Member"}, member.Roles) + assert.False(t, member.IsAdmin) + assert.Equal(t, coredata.MFAStatusDisabled, member.MFAStatus) + assert.Nil(t, member.Active) +} + +// TestRailwayRecords drives the cross-workspace aggregation directly (the +// cassette has a single workspace, so it cannot exercise dedup). A member who +// appears in two workspaces yields one record with unioned roles, IsAdmin true +// if any appearance is ADMIN, and MFA enabled if any appearance reports it; a +// member whose two-factor flag is null in every workspace stays Unknown. +func TestRailwayRecords(t *testing.T) { + t.Parallel() + + enabled := true + disabled := false + + me := &railwayMe{ + Workspaces: []railwayWorkspace{ + { + ID: "ws-a", + Name: "Alpha", + Members: []railwayMember{ + {ID: "u-alice", Email: "alice@example.com", Name: "Alice", Role: "ADMIN", TwoFactorAuthEnabled: &enabled}, + {ID: "u-bob", Email: "bob@example.com", Name: "Bob", Role: "MEMBER", TwoFactorAuthEnabled: &disabled}, + {ID: "u-carol", Email: "carol@example.com", Name: "Carol", Role: "VIEWER"}, + }, + }, + { + ID: "ws-b", + Name: "Beta", + Members: []railwayMember{ + {ID: "u-alice", Email: "alice@example.com", Name: "Alice", Role: "MEMBER", TwoFactorAuthEnabled: &disabled}, + {ID: "u-carol", Email: "carol@example.com", Name: "Carol", Role: "VIEWER"}, + }, + }, + }, + } + + records := railwayRecords(me) + require.Len(t, records, 3) + + byID := make(map[string]AccountRecord, len(records)) + for _, r := range records { + byID[r.ExternalID] = r + } + + alice := byID["u-alice"] + assert.Equal(t, []string{"Admin", "Member"}, alice.Roles) + assert.True(t, alice.IsAdmin) + assert.Equal(t, coredata.MFAStatusEnabled, alice.MFAStatus) + assert.Nil(t, alice.Active) + + bob := byID["u-bob"] + assert.Equal(t, []string{"Member"}, bob.Roles) + assert.False(t, bob.IsAdmin) + assert.Equal(t, coredata.MFAStatusDisabled, bob.MFAStatus) + + carol := byID["u-carol"] + assert.Equal(t, []string{"Viewer"}, carol.Roles) + assert.False(t, carol.IsAdmin) + assert.Equal(t, coredata.MFAStatusUnknown, carol.MFAStatus) +} diff --git a/pkg/accessreview/drivers/scaleway.go b/pkg/accessreview/drivers/scaleway.go new file mode 100644 index 000000000..120ba69dd --- /dev/null +++ b/pkg/accessreview/drivers/scaleway.go @@ -0,0 +1,237 @@ +// 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" +) + +const ( + scalewayAPIHost = "api.scaleway.com" + scalewayUsersPath = "/iam/v1alpha1/users" + scalewayPageSize = 100 +) + +// ScalewayDriver lists the IAM users of a single Scaleway Organization. The +// secret key (sent in the X-Auth-Token header by the connection transport) is +// scoped to one Organization, but GET /iam/v1alpha1/users requires the +// organization_id explicitly, so it is captured up front as a connector +// setting rather than discovered. +type ScalewayDriver struct { + httpClient *http.Client + organizationID string +} + +var _ Driver = (*ScalewayDriver)(nil) + +type scalewayUsersResponse struct { + Users []scalewayUser `json:"users"` + TotalCount uint32 `json:"total_count"` +} + +type scalewayUser struct { + ID string `json:"id"` + Email string `json:"email"` + Username string `json:"username"` + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + CreatedAt string `json:"created_at"` + LastLoginAt *string `json:"last_login_at"` + // Type is the org-level user type ("owner" | "member"). Fine-grained IAM + // roles live on separate policy/group endpoints and are out of scope. + Type string `json:"type"` + Status string `json:"status"` + // MFA is always present; TwoFactorEnabled is the newer pointer mirror of + // the same state and is preferred when set (see scalewayMFAStatus). + MFA bool `json:"mfa"` + TwoFactorEnabled *bool `json:"two_factor_enabled"` + Locked bool `json:"locked"` +} + +func NewScalewayDriver(httpClient *http.Client, organizationID string) *ScalewayDriver { + return &ScalewayDriver{ + httpClient: httpClient, + organizationID: organizationID, + } +} + +func (d *ScalewayDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error) { + var records []AccountRecord + + fetched := 0 + page := 1 + + for range maxPaginationPages { + resp, err := d.fetchPage(ctx, page) + if err != nil { + return nil, err + } + + for _, u := range resp.Users { + email := strings.TrimSpace(u.Email) + if email == "" { + continue + } + + record := AccountRecord{ + Email: email, + FullName: scalewayFullName(u, email), + Roles: scalewayRoles(u.Type), + Active: scalewayActive(u.Status, u.Locked), + IsAdmin: strings.EqualFold(strings.TrimSpace(u.Type), "owner"), + MFAStatus: scalewayMFAStatus(u), + AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown, + AccountType: coredata.AccessReviewEntryAccountTypeUser, + CreatedAt: parseRFC3339Ptr(u.CreatedAt), + ExternalID: strings.TrimSpace(u.ID), + } + + if u.LastLoginAt != nil { + record.LastLogin = parseRFC3339Ptr(*u.LastLoginAt) + } + + records = append(records, record) + } + + fetched += len(resp.Users) + if len(resp.Users) < scalewayPageSize || uint32(fetched) >= resp.TotalCount { + return records, nil + } + + page++ + } + + return nil, fmt.Errorf("cannot list all scaleway accounts: %w", ErrPaginationLimitReached) +} + +func (d *ScalewayDriver) fetchPage(ctx context.Context, page int) (*scalewayUsersResponse, error) { + q := url.Values{} + q.Set("organization_id", d.organizationID) + q.Set("order_by", "created_at_asc") + q.Set("page", strconv.Itoa(page)) + q.Set("page_size", strconv.Itoa(scalewayPageSize)) + + endpoint := url.URL{ + Scheme: "https", + Host: scalewayAPIHost, + Path: scalewayUsersPath, + RawQuery: q.Encode(), + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil) + if err != nil { + return nil, fmt.Errorf("cannot create scaleway 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 scaleway users request: %w", err) + } + + defer func() { _ = httpResp.Body.Close() }() + + if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 { + return nil, fmt.Errorf("cannot fetch scaleway users: unexpected status %d", httpResp.StatusCode) + } + + var resp scalewayUsersResponse + if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil { + return nil, fmt.Errorf("cannot decode scaleway users response: %w", err) + } + + return &resp, nil +} + +func scalewayFullName(u scalewayUser, fallback string) string { + if name := strings.TrimSpace(u.FirstName + " " + u.LastName); name != "" { + return name + } + + if username := strings.TrimSpace(u.Username); username != "" { + return username + } + + return fallback +} + +// scalewayRoles maps the Scaleway org-level user type to a display label. The +// users endpoint exposes only owner/member; an unknown future value is passed +// through verbatim and no type yields an empty slice. +func scalewayRoles(userType string) []string { + switch strings.ToLower(strings.TrimSpace(userType)) { + case "owner": + return []string{"Owner"} + case "member": + return []string{"Member"} + default: + if t := strings.TrimSpace(userType); t != "" { + return []string{t} + } + + return []string{} + } +} + +// scalewayActive maps the Scaleway user status to the three-valued Active +// signal. A locked account is always inactive; otherwise only the documented +// "activated"/"invitation_pending" values are an explicit signal and any other +// or missing status leaves Active nil (no signal). The literal live value is +// "activated", not "active", so the shared activeFromStatus helper is not used. +func scalewayActive(status string, locked bool) *bool { + if locked { + inactive := false + + return &inactive + } + + switch strings.ToLower(strings.TrimSpace(status)) { + case "activated": + active := true + + return &active + case "invitation_pending": + inactive := false + + return &inactive + default: + return nil + } +} + +// scalewayMFAStatus reads the two-factor state, preferring the newer +// two_factor_enabled pointer when present and otherwise the always-present mfa +// boolean. +func scalewayMFAStatus(u scalewayUser) coredata.MFAStatus { + enabled := u.MFA + if u.TwoFactorEnabled != nil { + enabled = *u.TwoFactorEnabled + } + + if enabled { + return coredata.MFAStatusEnabled + } + + return coredata.MFAStatusDisabled +} diff --git a/pkg/accessreview/drivers/scaleway_test.go b/pkg/accessreview/drivers/scaleway_test.go new file mode 100644 index 000000000..68abc6276 --- /dev/null +++ b/pkg/accessreview/drivers/scaleway_test.go @@ -0,0 +1,94 @@ +// 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 TestScalewayDriver(t *testing.T) { + t.Parallel() + + rec := newRecorder(t, "testdata/scaleway", "SCALEWAY_API_KEY") + // Scaleway authenticates via the X-Auth-Token header, not Authorization. + client := newVCRClientWithHeader(rec, "X-Auth-Token", os.Getenv("SCALEWAY_API_KEY")) + + driver := NewScalewayDriver(client, "11111111-2222-3333-4444-555555555555") + records, err := driver.ListAccounts(context.Background()) + require.NoError(t, err) + require.Len(t, records, 2) + + owner := records[0] + assert.Equal(t, "8a3f1b2c-9d4e-4a5f-8b6c-1d2e3f4a5b6c", owner.ExternalID) + assert.Equal(t, "alice.martin@example.com", owner.Email) + assert.Equal(t, "Alice Martin", owner.FullName) + assert.Equal(t, []string{"Owner"}, owner.Roles) + assert.True(t, owner.IsAdmin) + require.NotNil(t, owner.Active) + assert.True(t, *owner.Active) + assert.Equal(t, coredata.MFAStatusEnabled, owner.MFAStatus) + assert.NotNil(t, owner.CreatedAt) + assert.NotNil(t, owner.LastLogin) + assert.Equal(t, coredata.AccessReviewEntryAccountTypeUser, owner.AccountType) + + // Owner is the only admin; an invitation-pending member is inactive with no + // last-login timestamp. + member := records[1] + assert.Equal(t, "c7e2a9d4-5f6b-4c3a-9e8d-2b1c0a9f8e7d", member.ExternalID) + assert.Equal(t, "bob.dupont@example.com", member.Email) + assert.Equal(t, []string{"Member"}, member.Roles) + assert.False(t, member.IsAdmin) + require.NotNil(t, member.Active) + assert.False(t, *member.Active) + assert.Equal(t, coredata.MFAStatusDisabled, member.MFAStatus) + assert.Nil(t, member.LastLogin) +} + +func TestScalewayMFAStatus(t *testing.T) { + t.Parallel() + + enabled := true + disabled := false + + // The always-present mfa boolean is the fallback; the newer + // two_factor_enabled pointer wins when set. + assert.Equal(t, coredata.MFAStatusEnabled, scalewayMFAStatus(scalewayUser{MFA: true})) + assert.Equal(t, coredata.MFAStatusDisabled, scalewayMFAStatus(scalewayUser{MFA: false})) + assert.Equal(t, coredata.MFAStatusEnabled, scalewayMFAStatus(scalewayUser{MFA: false, TwoFactorEnabled: &enabled})) + assert.Equal(t, coredata.MFAStatusDisabled, scalewayMFAStatus(scalewayUser{MFA: true, TwoFactorEnabled: &disabled})) +} + +func TestScalewayActive(t *testing.T) { + t.Parallel() + + mustBool := func(t *testing.T, want bool, got *bool) { + t.Helper() + require.NotNil(t, got) + assert.Equal(t, want, *got) + } + + mustBool(t, true, scalewayActive("activated", false)) + mustBool(t, false, scalewayActive("invitation_pending", false)) + // A locked account is inactive even when its status is "activated". + mustBool(t, false, scalewayActive("activated", true)) + assert.Nil(t, scalewayActive("", false)) + assert.Nil(t, scalewayActive("unknown_status", false)) +} diff --git a/pkg/accessreview/drivers/testdata/crisp.yaml b/pkg/accessreview/drivers/testdata/crisp.yaml new file mode 100644 index 000000000..f27b9cbd2 --- /dev/null +++ b/pkg/accessreview/drivers/testdata/crisp.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.crisp.chat + headers: + Accept: + - application/json + X-Crisp-Tier: + - plugin + url: https://api.crisp.chat/v1/website/1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d/operators/list + method: GET + response: + proto: HTTP/2.0 + proto_major: 2 + proto_minor: 0 + content_length: -1 + uncompressed: true + body: '{"error":false,"reason":"listed","data":[{"type":"member","details":{"user_id":"5c068745-c7da-4b59-89a0-1b67f3b0d6df","email":"alex@example.com","first_name":"Alex","last_name":"Martin","role":"owner","title":"Founder","availability":"online","has_token":false}},{"type":"member","details":{"user_id":"9a1f3c2e-6b4d-4f8a-bc11-7d2e9f0a1b22","email":"jordan@example.com","first_name":"Jordan","last_name":"Lee","role":"member","title":"Support Agent","availability":"away","has_token":false}}]}' + headers: + Content-Type: + - application/json + status: 200 OK + code: 200 + duration: 100ms diff --git a/pkg/accessreview/drivers/testdata/railway.yaml b/pkg/accessreview/drivers/testdata/railway.yaml new file mode 100644 index 000000000..c67001c9c --- /dev/null +++ b/pkg/accessreview/drivers/testdata/railway.yaml @@ -0,0 +1,31 @@ +--- +version: 2 +interactions: + - id: 0 + request: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + content_length: 117 + host: backboard.railway.com + body: '{"query":"query { me { id name email workspaces { id name members { id email name role twoFactorAuthEnabled } } } }"}' + headers: + Accept: + - application/json + Content-Type: + - application/json + url: https://backboard.railway.com/graphql/v2 + method: POST + response: + proto: HTTP/2.0 + proto_major: 2 + proto_minor: 0 + content_length: -1 + uncompressed: true + body: '{"data":{"me":{"id":"8f7e6d5c-4b3a-2910-8a7b-6c5d4e3f2a1b","name":"Ada Lovelace","email":"ada@example.com","workspaces":[{"id":"3c1d9e8f-7a6b-5c4d-3e2f-1a0b9c8d7e6f","name":"Probo","members":[{"id":"8f7e6d5c-4b3a-2910-8a7b-6c5d4e3f2a1b","email":"ada@example.com","name":"Ada Lovelace","role":"ADMIN","twoFactorAuthEnabled":true},{"id":"1b2c3d4e-5f6a-7b8c-9d0e-1f2a3b4c5d6e","email":"grace@example.com","name":"Grace Hopper","role":"MEMBER","twoFactorAuthEnabled":false}]}]}}}' + headers: + Content-Type: + - application/json + status: 200 OK + code: 200 + duration: 100ms diff --git a/pkg/accessreview/drivers/testdata/scaleway.yaml b/pkg/accessreview/drivers/testdata/scaleway.yaml new file mode 100644 index 000000000..7b6235a0b --- /dev/null +++ b/pkg/accessreview/drivers/testdata/scaleway.yaml @@ -0,0 +1,37 @@ +--- +version: 2 +interactions: + - id: 0 + request: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + content_length: 0 + host: api.scaleway.com + form: + order_by: + - created_at_asc + organization_id: + - 11111111-2222-3333-4444-555555555555 + page: + - "1" + page_size: + - "100" + headers: + Accept: + - application/json + url: https://api.scaleway.com/iam/v1alpha1/users?order_by=created_at_asc&organization_id=11111111-2222-3333-4444-555555555555&page=1&page_size=100 + method: GET + response: + proto: HTTP/2.0 + proto_major: 2 + proto_minor: 0 + content_length: -1 + uncompressed: true + body: '{"users":[{"id":"8a3f1b2c-9d4e-4a5f-8b6c-1d2e3f4a5b6c","email":"alice.martin@example.com","username":"alice.martin@example.com","first_name":"Alice","last_name":"Martin","created_at":"2023-04-12T09:15:42.123456Z","organization_id":"11111111-2222-3333-4444-555555555555","last_login_at":"2025-06-20T08:01:33.000000Z","type":"owner","two_factor_enabled":true,"status":"activated","mfa":true,"locked":false},{"id":"c7e2a9d4-5f6b-4c3a-9e8d-2b1c0a9f8e7d","email":"bob.dupont@example.com","username":"bob.dupont@example.com","first_name":"Bob","last_name":"Dupont","created_at":"2024-01-20T16:45:10.000000Z","organization_id":"11111111-2222-3333-4444-555555555555","last_login_at":null,"type":"member","two_factor_enabled":false,"status":"invitation_pending","mfa":false,"locked":false}],"total_count":2}' + headers: + Content-Type: + - application/json + status: 200 OK + code: 200 + duration: 100ms diff --git a/pkg/accessreview/drivers/testdata/yousign.yaml b/pkg/accessreview/drivers/testdata/yousign.yaml new file mode 100644 index 000000000..52594bccc --- /dev/null +++ b/pkg/accessreview/drivers/testdata/yousign.yaml @@ -0,0 +1,31 @@ +--- +version: 2 +interactions: + - id: 0 + request: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + content_length: 0 + host: api.yousign.app + form: + limit: + - "100" + headers: + Accept: + - application/json + url: https://api.yousign.app/v3/users?limit=100 + method: GET + response: + proto: HTTP/2.0 + proto_major: 2 + proto_minor: 0 + content_length: -1 + uncompressed: true + body: '{"meta":{"next_cursor":null},"data":[{"id":"9a93d3b5-fb3b-4abf-9e70-26315b33506c","first_name":"John","last_name":"Doe","email":"john.doe@example.com","locale":"en","job_title":"Legal Counsel","is_active":true,"created_at":"2024-01-18T22:59:00Z","role":"admin","status":"verified"},{"id":"b2f4e1c8-6d3a-4e2b-8f1a-9d5c7e8a0b3f","first_name":"Marie","last_name":"Martin","email":"marie.martin@example.com","locale":"fr","job_title":"Sales Manager","is_active":false,"created_at":"2024-03-02T09:15:00Z","role":"member","status":"invited"}]}' + headers: + Content-Type: + - application/json + status: 200 OK + code: 200 + duration: 100ms diff --git a/pkg/accessreview/drivers/vcr_test.go b/pkg/accessreview/drivers/vcr_test.go index e170ff6ce..805d3d478 100644 --- a/pkg/accessreview/drivers/vcr_test.go +++ b/pkg/accessreview/drivers/vcr_test.go @@ -58,6 +58,8 @@ func newRecorder(t *testing.T, cassettePath string, envVar string) *recorder.Rec i.Request.Headers.Del("X-Api-Key") i.Request.Headers.Del("Signoz-Api-Key") i.Request.Headers.Del("Api-Key") + // Scaleway authenticates with the secret key in X-Auth-Token. + i.Request.Headers.Del("X-Auth-Token") return nil }, recorder.BeforeSaveHook), diff --git a/pkg/accessreview/drivers/yousign.go b/pkg/accessreview/drivers/yousign.go new file mode 100644 index 000000000..b5fd6fe36 --- /dev/null +++ b/pkg/accessreview/drivers/yousign.go @@ -0,0 +1,187 @@ +// 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" +) + +const ( + yousignAPIHost = "api.yousign.app" + yousignUsersPath = "/v3/users" + yousignPageSize = 100 +) + +// YousignDriver lists the members of a single Yousign organization. A Yousign +// API key is bound to exactly one organization, so GET /v3/users returns every +// member with no tenant selector (Pattern 3). The Bearer credential is applied +// by the connection transport. The connector targets Yousign production; the +// sandbox runs on a separate host and is not a reviewed environment. +type YousignDriver struct { + httpClient *http.Client +} + +var _ Driver = (*YousignDriver)(nil) + +type yousignUsersResponse struct { + Meta struct { + NextCursor *string `json:"next_cursor"` + } `json:"meta"` + Data []yousignUser `json:"data"` +} + +type yousignUser struct { + ID string `json:"id"` + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + Email string `json:"email"` + JobTitle string `json:"job_title"` + IsActive bool `json:"is_active"` + CreatedAt string `json:"created_at"` + Role string `json:"role"` +} + +func NewYousignDriver(httpClient *http.Client) *YousignDriver { + return &YousignDriver{httpClient: httpClient} +} + +func (d *YousignDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error) { + var records []AccountRecord + + after := "" + + for range maxPaginationPages { + resp, err := d.fetchPage(ctx, after) + if err != nil { + return nil, err + } + + for _, u := range resp.Data { + email := strings.TrimSpace(u.Email) + if email == "" { + continue + } + + active := u.IsActive + + records = append(records, AccountRecord{ + Email: email, + FullName: yousignFullName(u, email), + Roles: yousignRoles(u.Role), + JobTitle: strings.TrimSpace(u.JobTitle), + Active: &active, + IsAdmin: yousignIsAdmin(u.Role), + MFAStatus: coredata.MFAStatusUnknown, + AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown, + AccountType: coredata.AccessReviewEntryAccountTypeUser, + CreatedAt: parseRFC3339Ptr(u.CreatedAt), + ExternalID: strings.TrimSpace(u.ID), + }) + } + + if resp.Meta.NextCursor == nil || strings.TrimSpace(*resp.Meta.NextCursor) == "" { + return records, nil + } + + after = *resp.Meta.NextCursor + } + + return nil, fmt.Errorf("cannot list all yousign accounts: %w", ErrPaginationLimitReached) +} + +func (d *YousignDriver) fetchPage(ctx context.Context, after string) (*yousignUsersResponse, error) { + q := url.Values{} + q.Set("limit", strconv.Itoa(yousignPageSize)) + + if after != "" { + q.Set("after", after) + } + + endpoint := url.URL{ + Scheme: "https", + Host: yousignAPIHost, + Path: yousignUsersPath, + RawQuery: q.Encode(), + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil) + if err != nil { + return nil, fmt.Errorf("cannot create yousign 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 yousign users request: %w", err) + } + + defer func() { _ = httpResp.Body.Close() }() + + if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 { + return nil, fmt.Errorf("cannot fetch yousign users: unexpected status %d", httpResp.StatusCode) + } + + var resp yousignUsersResponse + if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil { + return nil, fmt.Errorf("cannot decode yousign users response: %w", err) + } + + return &resp, nil +} + +func yousignFullName(u yousignUser, fallback string) string { + if name := strings.TrimSpace(u.FirstName + " " + u.LastName); name != "" { + return name + } + + return fallback +} + +// yousignRoles maps Yousign's single role string to a display label. Documented +// roles are admin/member, with owner reserved for the org owner; an unknown +// future value is passed through verbatim and no role yields an empty slice. +func yousignRoles(role string) []string { + switch strings.ToLower(strings.TrimSpace(role)) { + case "admin": + return []string{"Admin"} + case "owner": + return []string{"Owner"} + case "member": + return []string{"Member"} + default: + if r := strings.TrimSpace(role); r != "" { + return []string{r} + } + + return []string{} + } +} + +// yousignIsAdmin reports whether a Yousign role grants administration. Owner is +// strictly more privileged than admin, so both qualify; the match is exact, not +// a substring. +func yousignIsAdmin(role string) bool { + return strings.EqualFold(strings.TrimSpace(role), "admin") || + strings.EqualFold(strings.TrimSpace(role), "owner") +} diff --git a/pkg/accessreview/drivers/yousign_test.go b/pkg/accessreview/drivers/yousign_test.go new file mode 100644 index 000000000..561934b8f --- /dev/null +++ b/pkg/accessreview/drivers/yousign_test.go @@ -0,0 +1,72 @@ +// 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 TestYousignDriver(t *testing.T) { + t.Parallel() + + rec := newRecorder(t, "testdata/yousign", "YOUSIGN_API_KEY") + client := newVCRClient(rec, bearerAuth(os.Getenv("YOUSIGN_API_KEY"))) + + driver := NewYousignDriver(client) + records, err := driver.ListAccounts(context.Background()) + require.NoError(t, err) + require.Len(t, records, 2) + + admin := records[0] + assert.Equal(t, "9a93d3b5-fb3b-4abf-9e70-26315b33506c", admin.ExternalID) + assert.Equal(t, "john.doe@example.com", admin.Email) + assert.Equal(t, "John Doe", admin.FullName) + assert.Equal(t, []string{"Admin"}, admin.Roles) + assert.True(t, admin.IsAdmin) + require.NotNil(t, admin.Active) + assert.True(t, *admin.Active) + assert.Equal(t, "Legal Counsel", admin.JobTitle) + assert.Equal(t, coredata.MFAStatusUnknown, admin.MFAStatus) + assert.NotNil(t, admin.CreatedAt) + assert.Equal(t, coredata.AccessReviewEntryAccountTypeUser, admin.AccountType) + + // An invited (is_active=false) member is inactive regardless of onboarding + // status. + member := records[1] + assert.Equal(t, "b2f4e1c8-6d3a-4e2b-8f1a-9d5c7e8a0b3f", member.ExternalID) + assert.Equal(t, []string{"Member"}, member.Roles) + assert.False(t, member.IsAdmin) + require.NotNil(t, member.Active) + assert.False(t, *member.Active) + assert.Equal(t, "Sales Manager", member.JobTitle) +} + +func TestYousignIsAdmin(t *testing.T) { + t.Parallel() + + // owner is strictly more privileged than admin, so both are admins; the + // match is exact and case-insensitive, never a substring. + assert.True(t, yousignIsAdmin("admin")) + assert.True(t, yousignIsAdmin("owner")) + assert.True(t, yousignIsAdmin("Admin")) + assert.False(t, yousignIsAdmin("member")) + assert.False(t, yousignIsAdmin("")) +} diff --git a/pkg/connector/provider/builtin.go b/pkg/connector/provider/builtin.go index f9226b555..1afcaeaa8 100644 --- a/pkg/connector/provider/builtin.go +++ b/pkg/connector/provider/builtin.go @@ -33,6 +33,7 @@ func NewBuiltinRegistry() *Registry { clickhouseRegistration(), clickupRegistration(), cloudflareRegistration(), + crispRegistration(), cursorRegistration(), datadogRegistration(), deepgramRegistration(), @@ -62,8 +63,10 @@ func NewBuiltinRegistry() *Registry { pagerdutyRegistration(), pylonRegistration(), qoveryRegistration(), + railwayRegistration(), renderRegistration(), resendRegistration(), + scalewayRegistration(), sendgridRegistration(), sentryRegistration(), signozRegistration(), @@ -72,6 +75,7 @@ func NewBuiltinRegistry() *Registry { tailscaleRegistration(), tallyRegistration(), vercelRegistration(), + yousignRegistration(), zendeskRegistration(), } { if err := r.Register(reg); err != nil { diff --git a/pkg/connector/provider/crisp.go b/pkg/connector/provider/crisp.go new file mode 100644 index 000000000..3b4d9574c --- /dev/null +++ b/pkg/connector/provider/crisp.go @@ -0,0 +1,68 @@ +// 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 provider + +import ( + "context" + "fmt" + "net/http" + + "go.gearno.de/kit/log" + "go.probo.inc/probo/pkg/accessreview/drivers" + "go.probo.inc/probo/pkg/coredata" +) + +func crispRegistration() *Registration { + return &Registration{ + Provider: coredata.ConnectorProviderCrisp, + DisplayName: "Crisp", + SupportsAPIKey: true, + // Crisp authenticates with a plugin token presented as HTTP Basic, the + // credential being the verbatim "identifier:key" pair. + // APIKeyBasicAuthUserPass base64-encodes it (the empty-password + // APIKeyBasicAuth cannot carry the key). A plugin token can serve + // several websites, so the reviewed website is captured via + // ExtraSettings. Every request also needs the non-auth X-Crisp-Tier + // header (set by the driver/probe/name resolver), so the probe is a + // custom closure. + APIKeyBasicAuthUserPass: true, + ExtraSettings: []ExtraSetting{ + {Key: "websiteId", Label: "Website ID", Required: true}, + }, + Probe: probeCrisp, + NewDriver: func(_ context.Context, c *http.Client, conn *coredata.Connector, _ *log.Logger) (drivers.Driver, error) { + s, err := coredata.ConnectorSettings[coredata.CrispConnectorSettings](conn) + if err != nil { + return nil, fmt.Errorf("cannot read crisp connector settings: %w", err) + } + + if s.WebsiteID == "" { + return nil, fmt.Errorf("cannot create crisp driver: website_id is required") + } + + return drivers.NewCrispDriver(c, s.WebsiteID), nil + }, + NewNameResolver: func(ctx context.Context, c *http.Client, conn *coredata.Connector, logger *log.Logger) drivers.NameResolver { + s, err := coredata.ConnectorSettings[coredata.CrispConnectorSettings](conn) + if err != nil { + logger.ErrorCtx(ctx, "cannot read crisp connector settings", log.Error(err)) + + return nil + } + + return drivers.NewCrispNameResolver(c, s.WebsiteID) + }, + } +} diff --git a/pkg/connector/provider/probe.go b/pkg/connector/provider/probe.go index 5334d0dbc..b14778055 100644 --- a/pkg/connector/provider/probe.go +++ b/pkg/connector/provider/probe.go @@ -36,6 +36,7 @@ const ( openRouterMembersProbeURL = "https://openrouter.ai/api/v1/organization/members?limit=1" linearGraphQLEndpoint = "https://api.linear.app/graphql" mondayGraphQLEndpoint = "https://api.monday.com/v2" + railwayGraphQLEndpoint = "https://backboard.railway.com/graphql/v2" posthogOrganizationPath = "/api/organizations/@current/" posthogUSBaseURL = "https://us.posthog.com" posthogEUBaseURL = "https://eu.posthog.com" @@ -233,6 +234,29 @@ func buildNeonProbeURL(conn *coredata.Connector) (string, error) { return endpoint + "?" + q.Encode(), nil } +func buildScalewayProbeURL(conn *coredata.Connector) (string, error) { + s, err := coredata.ConnectorSettings[coredata.ScalewayConnectorSettings](conn) + if err != nil { + return "", fmt.Errorf("cannot read scaleway connector settings: %w", err) + } + + if s.OrganizationID == "" { + return "", fmt.Errorf("missing scaleway organization_id") + } + + endpoint := url.URL{ + Scheme: "https", + Host: "api.scaleway.com", + Path: "/iam/v1alpha1/users", + RawQuery: url.Values{ + "organization_id": {s.OrganizationID}, + "page_size": {"1"}, + }.Encode(), + } + + return endpoint.String(), nil +} + func buildRenderProbeURL(conn *coredata.Connector) (string, error) { s, err := coredata.ConnectorSettings[coredata.RenderConnectorSettings](conn) if err != nil { @@ -404,6 +428,99 @@ func probeMonday( ) } +// probeRailway verifies a Railway account token. Railway returns HTTP 200 with +// a populated errors array (and data.me null) for a rejected token rather than +// 401/403, so the generic probe would falsely pass — this closure inspects the +// response body instead. +func probeRailway( + ctx context.Context, + httpClient *http.Client, + _ *coredata.Connector, +) error { + body, err := json.Marshal(map[string]string{"query": "query { me { id } }"}) + if err != nil { + return fmt.Errorf("cannot marshal railway probe request: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, railwayGraphQLEndpoint, bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("cannot create railway probe request: %w", err) + } + + req.Header.Set("Accept", "application/json") + req.Header.Set("Content-Type", "application/json") + + resp, err := httpClient.Do(req) + if err != nil { + return fmt.Errorf("railway probe request failed: %w", err) + } + + defer func() { + _, _ = io.Copy(io.Discard, resp.Body) + _ = resp.Body.Close() + }() + + if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden { + return fmt.Errorf("credential rejected: status %d", resp.StatusCode) + } + + var parsed struct { + Data struct { + Me *struct { + ID string `json:"id"` + } `json:"me"` + } `json:"data"` + Errors []json.RawMessage `json:"errors"` + } + if err := json.NewDecoder(resp.Body).Decode(&parsed); err != nil { + return fmt.Errorf("cannot decode railway probe response: %w", err) + } + + if len(parsed.Errors) > 0 || parsed.Data.Me == nil { + return fmt.Errorf("credential rejected: railway returned no authenticated account") + } + + return nil +} + +// probeCrisp verifies a Crisp plugin token against the configured website. +// Every Crisp request needs the non-auth X-Crisp-Tier header, which the default +// probeGET does not set, so this closure builds the request itself; the Basic +// credential is attached by the connection transport. Beyond the usual 401/403, +// it treats 404 as a rejection too: a valid token whose website_id is wrong or +// unbound returns 404 on operators/list — a permanent misconfiguration that +// would otherwise pass the probe and fail every later access review, so it +// surfaces at connection time instead. +func probeCrisp( + ctx context.Context, + httpClient *http.Client, + conn *coredata.Connector, +) error { + s, err := coredata.ConnectorSettings[coredata.CrispConnectorSettings](conn) + if err != nil { + return fmt.Errorf("cannot read crisp connector settings: %w", err) + } + + if s.WebsiteID == "" { + return fmt.Errorf("missing crisp website_id") + } + + endpoint, err := url.JoinPath("https://api.crisp.chat/v1", "website", url.PathEscape(s.WebsiteID), "operators", "list") + if err != nil { + return fmt.Errorf("cannot build crisp probe URL: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return fmt.Errorf("cannot create crisp probe request: %w", err) + } + + req.Header.Set("Accept", "application/json") + req.Header.Set("X-Crisp-Tier", "plugin") + + return doProbeRequest(httpClient, req, http.StatusNotFound) +} + func probeAnthropic( ctx context.Context, httpClient *http.Client, diff --git a/pkg/connector/provider/probe_test.go b/pkg/connector/provider/probe_test.go index f6c00e13b..cca9ae9dd 100644 --- a/pkg/connector/provider/probe_test.go +++ b/pkg/connector/provider/probe_test.go @@ -16,7 +16,9 @@ package provider import ( "context" + "io" "net/http" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -112,6 +114,23 @@ func TestBuildPostHogProbeURL(t *testing.T) { assert.Equal(t, "https://us.posthog.com/api/organizations/@current/", probeURL) } +func TestBuildScalewayProbeURL(t *testing.T) { + t.Parallel() + + conn := &coredata.Connector{Provider: coredata.ConnectorProviderScaleway} + require.NoError(t, conn.SetSettings(&coredata.ScalewayConnectorSettings{ + OrganizationID: "11111111-2222-3333-4444-555555555555", + })) + + probeURL, err := buildScalewayProbeURL(conn) + require.NoError(t, err) + assert.Equal( + t, + "https://api.scaleway.com/iam/v1alpha1/users?organization_id=11111111-2222-3333-4444-555555555555&page_size=1", + probeURL, + ) +} + func TestProbeOpenRouter(t *testing.T) { t.Parallel() @@ -197,3 +216,101 @@ func TestProbeHeroku(t *testing.T) { }) } } + +func TestProbeRailway(t *testing.T) { + t.Parallel() + + // Railway returns HTTP 200 with a populated errors array (data.me null) for + // a rejected token instead of 401/403, so the probe must inspect the body — + // the generic 401/403-only contract would falsely accept a dead token. + cases := []struct { + name string + status int + body string + wantReject bool + }{ + {"valid token", http.StatusOK, `{"data":{"me":{"id":"u-1"}}}`, false}, + {"rejected token (200 + errors)", http.StatusOK, `{"errors":[{"message":"Not Authorized"}],"data":null}`, true}, + {"null me", http.StatusOK, `{"data":{"me":null}}`, true}, + {"unauthorized status", http.StatusUnauthorized, ``, true}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + var gotURL, gotContentType string + + client := &http.Client{Transport: probeRoundTripFunc(func(r *http.Request) (*http.Response, error) { + gotURL = r.URL.String() + gotContentType = r.Header.Get("Content-Type") + + return &http.Response{ + StatusCode: tc.status, + Body: io.NopCloser(strings.NewReader(tc.body)), + Header: make(http.Header), + }, nil + })} + + err := probeRailway(context.Background(), client, &coredata.Connector{Provider: coredata.ConnectorProviderRailway}) + + assert.Equal(t, "https://backboard.railway.com/graphql/v2", gotURL) + assert.Equal(t, "application/json", gotContentType) + + if tc.wantReject { + require.Error(t, err) + } else { + require.NoError(t, err) + } + }) + } +} + +func TestProbeCrisp(t *testing.T) { + t.Parallel() + + // probeCrisp must send the non-auth X-Crisp-Tier header (the generic + // probeGET does not) and hit the configured website's operators/list + // endpoint; 401/403 mean a rejected credential, and 404 means a valid token + // pointed at a wrong/unbound website_id — a permanent misconfiguration that + // must be rejected at connect time rather than fail every later review. + cases := []struct { + name string + status int + wantReject bool + }{ + {"valid token", http.StatusOK, false}, + {"revoked token", http.StatusUnauthorized, true}, + {"forbidden token", http.StatusForbidden, true}, + {"wrong or unbound website (404)", http.StatusNotFound, true}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + conn := &coredata.Connector{Provider: coredata.ConnectorProviderCrisp} + require.NoError(t, conn.SetSettings(&coredata.CrispConnectorSettings{WebsiteID: "abc-123"})) + + var gotURL, gotTier string + + client := &http.Client{Transport: probeRoundTripFunc(func(r *http.Request) (*http.Response, error) { + gotURL = r.URL.String() + gotTier = r.Header.Get("X-Crisp-Tier") + + return &http.Response{StatusCode: tc.status, Body: http.NoBody, Header: make(http.Header)}, nil + })} + + err := probeCrisp(context.Background(), client, conn) + + assert.Equal(t, "https://api.crisp.chat/v1/website/abc-123/operators/list", gotURL) + assert.Equal(t, "plugin", gotTier) + + if tc.wantReject { + require.Error(t, err) + } else { + require.NoError(t, err) + } + }) + } +} diff --git a/pkg/connector/provider/railway.go b/pkg/connector/provider/railway.go new file mode 100644 index 000000000..543f60bcd --- /dev/null +++ b/pkg/connector/provider/railway.go @@ -0,0 +1,44 @@ +// 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 provider + +import ( + "context" + "net/http" + + "go.gearno.de/kit/log" + "go.probo.inc/probo/pkg/accessreview/drivers" + "go.probo.inc/probo/pkg/coredata" +) + +func railwayRegistration() *Registration { + return &Registration{ + Provider: coredata.ConnectorProviderRailway, + DisplayName: "Railway", + SupportsAPIKey: true, + // Railway authenticates with an account API token as Authorization: + // Bearer. A single GraphQL call resolves the account's workspaces and + // their members, so there is nothing to pick (Pattern 3). Railway + // returns HTTP 200 with an errors body for a rejected token, so the + // probe must inspect the body — hence a custom Probe. + Probe: probeRailway, + NewDriver: func(_ context.Context, c *http.Client, _ *coredata.Connector, _ *log.Logger) (drivers.Driver, error) { + return drivers.NewRailwayDriver(c), nil + }, + NewNameResolver: func(_ context.Context, c *http.Client, _ *coredata.Connector, _ *log.Logger) drivers.NameResolver { + return drivers.NewRailwayNameResolver(c) + }, + } +} diff --git a/pkg/connector/provider/scaleway.go b/pkg/connector/provider/scaleway.go new file mode 100644 index 000000000..dac0166b8 --- /dev/null +++ b/pkg/connector/provider/scaleway.go @@ -0,0 +1,59 @@ +// 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 provider + +import ( + "context" + "fmt" + "net/http" + + "go.gearno.de/kit/log" + "go.probo.inc/probo/pkg/accessreview/drivers" + "go.probo.inc/probo/pkg/coredata" +) + +func scalewayRegistration() *Registration { + return &Registration{ + Provider: coredata.ConnectorProviderScaleway, + DisplayName: "Scaleway", + SupportsAPIKey: true, + // Scaleway authenticates with the secret key in the X-Auth-Token header + // rather than Authorization: Bearer. APIKeyHeader makes the + // APIKeyConnection send that header and omit Authorization. The key is + // bound to one Organization, but GET /iam/v1alpha1/users requires the + // organization_id explicitly, so it is captured via ExtraSettings rather + // than discovered — hence no picker and a BuildProbeURL. + APIKeyHeader: "X-Auth-Token", + ExtraSettings: []ExtraSetting{ + {Key: "organizationId", Label: "Organization ID", Required: true}, + }, + BuildProbeURL: buildScalewayProbeURL, + // No NewNameResolver: Scaleway exposes no read-only endpoint that maps + // an Organization UUID to its display name, so the source keeps its + // generic name. + NewDriver: func(_ context.Context, c *http.Client, conn *coredata.Connector, _ *log.Logger) (drivers.Driver, error) { + s, err := coredata.ConnectorSettings[coredata.ScalewayConnectorSettings](conn) + if err != nil { + return nil, fmt.Errorf("cannot read scaleway connector settings: %w", err) + } + + if s.OrganizationID == "" { + return nil, fmt.Errorf("cannot create scaleway driver: organization_id is required") + } + + return drivers.NewScalewayDriver(c, s.OrganizationID), nil + }, + } +} diff --git a/pkg/connector/provider/yousign.go b/pkg/connector/provider/yousign.go new file mode 100644 index 000000000..44b0e2bfa --- /dev/null +++ b/pkg/connector/provider/yousign.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 provider + +import ( + "context" + "net/http" + + "go.gearno.de/kit/log" + "go.probo.inc/probo/pkg/accessreview/drivers" + "go.probo.inc/probo/pkg/coredata" +) + +func yousignRegistration() *Registration { + return &Registration{ + Provider: coredata.ConnectorProviderYousign, + DisplayName: "Yousign", + SupportsAPIKey: true, + // Yousign authenticates with an API key as Authorization: Bearer. The + // key is bound to one organization, so GET /v3/users returns everyone + // with nothing to pick (Pattern 3). The connector targets Yousign + // production; the sandbox runs on a separate host and is not a reviewed + // environment. + // + // ProbeURL lets the connection-status check confirm the key with a + // lightweight GET; the transport attaches the Bearer credential and a + // dead key returns 401/403. + // + // No NewNameResolver: Yousign v3 exposes no organization-name endpoint, + // so the source keeps its generic name. + ProbeURL: "https://api.yousign.app/v3/users?limit=1", + NewDriver: func(_ context.Context, c *http.Client, _ *coredata.Connector, _ *log.Logger) (drivers.Driver, error) { + return drivers.NewYousignDriver(c), nil + }, + } +} diff --git a/pkg/coredata/connector_provider.go b/pkg/coredata/connector_provider.go index 698e3e38c..a9a28b1e6 100644 --- a/pkg/coredata/connector_provider.go +++ b/pkg/coredata/connector_provider.go @@ -74,6 +74,10 @@ const ( ConnectorProviderOpenRouter ConnectorProvider = "OPENROUTER" ConnectorProviderIncidentIO ConnectorProvider = "INCIDENT_IO" ConnectorProviderBrevo ConnectorProvider = "BREVO" + ConnectorProviderScaleway ConnectorProvider = "SCALEWAY" + ConnectorProviderYousign ConnectorProvider = "YOUSIGN" + ConnectorProviderRailway ConnectorProvider = "RAILWAY" + ConnectorProviderCrisp ConnectorProvider = "CRISP" ) var ( @@ -135,6 +139,10 @@ func ConnectorProviders() []ConnectorProvider { ConnectorProviderOpenRouter, ConnectorProviderIncidentIO, ConnectorProviderBrevo, + ConnectorProviderScaleway, + ConnectorProviderYousign, + ConnectorProviderRailway, + ConnectorProviderCrisp, } } @@ -191,7 +199,11 @@ func (v ConnectorProvider) IsValid() bool { ConnectorProviderPylon, ConnectorProviderOpenRouter, ConnectorProviderIncidentIO, - ConnectorProviderBrevo: + ConnectorProviderBrevo, + ConnectorProviderScaleway, + ConnectorProviderYousign, + ConnectorProviderRailway, + ConnectorProviderCrisp: return true } diff --git a/pkg/coredata/connector_settings.go b/pkg/coredata/connector_settings.go index 813852c46..6d9e7bb2a 100644 --- a/pkg/coredata/connector_settings.go +++ b/pkg/coredata/connector_settings.go @@ -174,6 +174,20 @@ type ( LangfuseConnectorSettings struct { BaseURL string `json:"base_url"` } + + // ScalewayConnectorSettings stores the Scaleway Organization ID. A secret + // key is bound to one Organization, but GET /iam/v1alpha1/users requires + // the organization_id explicitly, so it is captured up front. + ScalewayConnectorSettings struct { + OrganizationID string `json:"organization_id"` + } + + // CrispConnectorSettings stores the Crisp Website ID. A plugin token can + // be connected to several websites, so the reviewed website is captured up + // front as the {website_id} path segment on /v1/website/{website_id}/... + CrispConnectorSettings struct { + WebsiteID string `json:"website_id"` + } ) // GrantType returns the OAuth2 grant type recorded on the connector's diff --git a/pkg/coredata/migrations/20260627T107898Z.sql b/pkg/coredata/migrations/20260627T107898Z.sql new file mode 100644 index 000000000..a4e9c07dd --- /dev/null +++ b/pkg/coredata/migrations/20260627T107898Z.sql @@ -0,0 +1,15 @@ +-- 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. + +ALTER TYPE connector_provider ADD VALUE IF NOT EXISTS 'CRISP'; diff --git a/pkg/coredata/migrations/20260627T580764Z.sql b/pkg/coredata/migrations/20260627T580764Z.sql new file mode 100644 index 000000000..de1369a1a --- /dev/null +++ b/pkg/coredata/migrations/20260627T580764Z.sql @@ -0,0 +1,15 @@ +-- 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. + +ALTER TYPE connector_provider ADD VALUE IF NOT EXISTS 'SCALEWAY'; diff --git a/pkg/coredata/migrations/20260627T668415Z.sql b/pkg/coredata/migrations/20260627T668415Z.sql new file mode 100644 index 000000000..3b7a2ce81 --- /dev/null +++ b/pkg/coredata/migrations/20260627T668415Z.sql @@ -0,0 +1,15 @@ +-- 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. + +ALTER TYPE connector_provider ADD VALUE IF NOT EXISTS 'RAILWAY'; diff --git a/pkg/coredata/migrations/20260627T820699Z.sql b/pkg/coredata/migrations/20260627T820699Z.sql new file mode 100644 index 000000000..dd8865704 --- /dev/null +++ b/pkg/coredata/migrations/20260627T820699Z.sql @@ -0,0 +1,15 @@ +-- 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. + +ALTER TYPE connector_provider ADD VALUE IF NOT EXISTS 'YOUSIGN'; diff --git a/pkg/server/api/console/v1/connector_settings.go b/pkg/server/api/console/v1/connector_settings.go index 08d10dd89..f952c5374 100644 --- a/pkg/server/api/console/v1/connector_settings.go +++ b/pkg/server/api/console/v1/connector_settings.go @@ -189,6 +189,18 @@ func apiKeyConnectorSettings(input types.CreateAPIKeyConnectorInput) (json.RawMe } return json.Marshal(&coredata.LangfuseConnectorSettings{BaseURL: *input.LangfuseBaseURL}) + case coredata.ConnectorProviderScaleway: + if input.ScalewayOrganizationID == nil || *input.ScalewayOrganizationID == "" { + return nil, fmt.Errorf("cannot create scaleway connector: scalewayOrganizationId is required") + } + + return json.Marshal(&coredata.ScalewayConnectorSettings{OrganizationID: *input.ScalewayOrganizationID}) + case coredata.ConnectorProviderCrisp: + if input.CrispWebsiteID == nil || *input.CrispWebsiteID == "" { + return nil, fmt.Errorf("cannot create crisp connector: crispWebsiteId is required") + } + + return json.Marshal(&coredata.CrispConnectorSettings{WebsiteID: *input.CrispWebsiteID}) } return nil, nil diff --git a/pkg/server/api/console/v1/graphql/connector.graphql b/pkg/server/api/console/v1/graphql/connector.graphql index dea99c01b..7120bf1a7 100644 --- a/pkg/server/api/console/v1/graphql/connector.graphql +++ b/pkg/server/api/console/v1/graphql/connector.graphql @@ -92,6 +92,13 @@ enum ConnectorProvider value: "go.probo.inc/probo/pkg/coredata.ConnectorProviderIncidentIO" ) BREVO @goEnum(value: "go.probo.inc/probo/pkg/coredata.ConnectorProviderBrevo") + SCALEWAY + @goEnum(value: "go.probo.inc/probo/pkg/coredata.ConnectorProviderScaleway") + YOUSIGN + @goEnum(value: "go.probo.inc/probo/pkg/coredata.ConnectorProviderYousign") + RAILWAY + @goEnum(value: "go.probo.inc/probo/pkg/coredata.ConnectorProviderRailway") + CRISP @goEnum(value: "go.probo.inc/probo/pkg/coredata.ConnectorProviderCrisp") } type ConnectorProviderInfo { @@ -174,6 +181,8 @@ input CreateAPIKeyConnectorInput { renderWorkspaceId: String neonOrganizationId: String langfuseBaseUrl: String + scalewayOrganizationId: String + crispWebsiteId: String } type CreateAPIKeyConnectorPayload {