Add Scaleway, Yousign, Railway and Crisp access-review connectors
Four API-key, single-tenant (Pattern 3) connectors:
- Scaleway: secret key in the X-Auth-Token header plus an Organization ID
setting; GET /iam/v1alpha1/users (owner/member, status, two-factor),
per-connection BuildProbeURL.
- Yousign: Bearer API key; GET /v3/users (admin/owner/member, is_active);
production host with a static probe.
- Railway: Bearer account token; GraphQL me{workspaces{members}} aggregated
and deduplicated across workspaces; custom probe, since Railway returns
HTTP 200 with an errors body on a rejected token.
- Crisp: plugin token as HTTP Basic (identifier:key) plus a Website ID
setting and the X-Crisp-Tier header; GET /v1/website/{id}/operators/list,
custom probe and name resolver.
Scaleway and Crisp carry a required extra setting, so the console add-source
dialog maps organizationId/websiteId onto their scalewayOrganizationId and
crispWebsiteId API-key inputs; without that mapping the value is silently
dropped and the create is rejected.
Cassette-backed driver tests plus unit tests for the cross-workspace
deduplication, the probe contracts and the role/MFA helpers.
Signed-off-by: Aurélien Sibiril <81782+aureliensibiril@users.noreply.github.com>
This commit is contained in:
@@ -160,6 +160,12 @@ function mapAPIKeyExtraSettingToField(
|
|||||||
case "NEON":
|
case "NEON":
|
||||||
if (settingKey === "organizationId") return "neonOrganizationId";
|
if (settingKey === "organizationId") return "neonOrganizationId";
|
||||||
break;
|
break;
|
||||||
|
case "SCALEWAY":
|
||||||
|
if (settingKey === "organizationId") return "scalewayOrganizationId";
|
||||||
|
break;
|
||||||
|
case "CRISP":
|
||||||
|
if (settingKey === "websiteId") return "crispWebsiteId";
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
151
pkg/accessreview/drivers/crisp.go
Normal file
151
pkg/accessreview/drivers/crisp.go
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
// Copyright (c) 2026 Probo Inc <hello@probo.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"
|
||||||
|
)
|
||||||
|
|
||||||
|
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{}
|
||||||
|
}
|
||||||
|
}
|
||||||
55
pkg/accessreview/drivers/crisp_test.go
Normal file
55
pkg/accessreview/drivers/crisp_test.go
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||||
|
//
|
||||||
|
// Permission to use, copy, modify, and/or distribute this software for any
|
||||||
|
// purpose with or without fee is hereby granted, provided that the above
|
||||||
|
// copyright notice and this permission notice appear in all copies.
|
||||||
|
//
|
||||||
|
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||||
|
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||||
|
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||||
|
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||||
|
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||||
|
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||||
|
// PERFORMANCE OF THIS SOFTWARE.
|
||||||
|
|
||||||
|
package drivers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"go.probo.inc/probo/pkg/coredata"
|
||||||
|
)
|
||||||
|
|
||||||
|
func 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)
|
||||||
|
}
|
||||||
@@ -1532,3 +1532,136 @@ func (r *microsoft365NameResolver) ResolveInstanceName(ctx context.Context) (str
|
|||||||
|
|
||||||
return "", nil
|
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
|
||||||
|
}
|
||||||
|
|||||||
279
pkg/accessreview/drivers/railway.go
Normal file
279
pkg/accessreview/drivers/railway.go
Normal file
@@ -0,0 +1,279 @@
|
|||||||
|
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||||
|
//
|
||||||
|
// Permission to use, copy, modify, and/or distribute this software for any
|
||||||
|
// purpose with or without fee is hereby granted, provided that the above
|
||||||
|
// copyright notice and this permission notice appear in all copies.
|
||||||
|
//
|
||||||
|
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||||
|
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||||
|
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||||
|
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||||
|
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||||
|
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||||
|
// PERFORMANCE OF THIS SOFTWARE.
|
||||||
|
|
||||||
|
package drivers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"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
|
||||||
|
}
|
||||||
114
pkg/accessreview/drivers/railway_test.go
Normal file
114
pkg/accessreview/drivers/railway_test.go
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||||
|
//
|
||||||
|
// Permission to use, copy, modify, and/or distribute this software for any
|
||||||
|
// purpose with or without fee is hereby granted, provided that the above
|
||||||
|
// copyright notice and this permission notice appear in all copies.
|
||||||
|
//
|
||||||
|
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||||
|
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||||
|
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||||
|
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||||
|
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||||
|
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||||
|
// PERFORMANCE OF THIS SOFTWARE.
|
||||||
|
|
||||||
|
package drivers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"go.probo.inc/probo/pkg/coredata"
|
||||||
|
)
|
||||||
|
|
||||||
|
func 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)
|
||||||
|
}
|
||||||
237
pkg/accessreview/drivers/scaleway.go
Normal file
237
pkg/accessreview/drivers/scaleway.go
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
// Copyright (c) 2026 Probo Inc <hello@probo.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"
|
||||||
|
)
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
94
pkg/accessreview/drivers/scaleway_test.go
Normal file
94
pkg/accessreview/drivers/scaleway_test.go
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||||
|
//
|
||||||
|
// Permission to use, copy, modify, and/or distribute this software for any
|
||||||
|
// purpose with or without fee is hereby granted, provided that the above
|
||||||
|
// copyright notice and this permission notice appear in all copies.
|
||||||
|
//
|
||||||
|
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||||
|
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||||
|
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||||
|
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||||
|
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||||
|
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||||
|
// PERFORMANCE OF THIS SOFTWARE.
|
||||||
|
|
||||||
|
package drivers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"go.probo.inc/probo/pkg/coredata"
|
||||||
|
)
|
||||||
|
|
||||||
|
func 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))
|
||||||
|
}
|
||||||
30
pkg/accessreview/drivers/testdata/crisp.yaml
vendored
Normal file
30
pkg/accessreview/drivers/testdata/crisp.yaml
vendored
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
---
|
||||||
|
version: 2
|
||||||
|
interactions:
|
||||||
|
- id: 0
|
||||||
|
request:
|
||||||
|
proto: HTTP/1.1
|
||||||
|
proto_major: 1
|
||||||
|
proto_minor: 1
|
||||||
|
content_length: 0
|
||||||
|
host: api.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
|
||||||
31
pkg/accessreview/drivers/testdata/railway.yaml
vendored
Normal file
31
pkg/accessreview/drivers/testdata/railway.yaml
vendored
Normal file
@@ -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
|
||||||
37
pkg/accessreview/drivers/testdata/scaleway.yaml
vendored
Normal file
37
pkg/accessreview/drivers/testdata/scaleway.yaml
vendored
Normal file
@@ -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
|
||||||
31
pkg/accessreview/drivers/testdata/yousign.yaml
vendored
Normal file
31
pkg/accessreview/drivers/testdata/yousign.yaml
vendored
Normal file
@@ -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
|
||||||
@@ -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("X-Api-Key")
|
||||||
i.Request.Headers.Del("Signoz-Api-Key")
|
i.Request.Headers.Del("Signoz-Api-Key")
|
||||||
i.Request.Headers.Del("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
|
return nil
|
||||||
}, recorder.BeforeSaveHook),
|
}, recorder.BeforeSaveHook),
|
||||||
|
|||||||
187
pkg/accessreview/drivers/yousign.go
Normal file
187
pkg/accessreview/drivers/yousign.go
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
// Copyright (c) 2026 Probo Inc <hello@probo.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"
|
||||||
|
)
|
||||||
|
|
||||||
|
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")
|
||||||
|
}
|
||||||
72
pkg/accessreview/drivers/yousign_test.go
Normal file
72
pkg/accessreview/drivers/yousign_test.go
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||||
|
//
|
||||||
|
// Permission to use, copy, modify, and/or distribute this software for any
|
||||||
|
// purpose with or without fee is hereby granted, provided that the above
|
||||||
|
// copyright notice and this permission notice appear in all copies.
|
||||||
|
//
|
||||||
|
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||||
|
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||||
|
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||||
|
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||||
|
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||||
|
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||||
|
// PERFORMANCE OF THIS SOFTWARE.
|
||||||
|
|
||||||
|
package drivers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"go.probo.inc/probo/pkg/coredata"
|
||||||
|
)
|
||||||
|
|
||||||
|
func 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(""))
|
||||||
|
}
|
||||||
@@ -33,6 +33,7 @@ func NewBuiltinRegistry() *Registry {
|
|||||||
clickhouseRegistration(),
|
clickhouseRegistration(),
|
||||||
clickupRegistration(),
|
clickupRegistration(),
|
||||||
cloudflareRegistration(),
|
cloudflareRegistration(),
|
||||||
|
crispRegistration(),
|
||||||
cursorRegistration(),
|
cursorRegistration(),
|
||||||
datadogRegistration(),
|
datadogRegistration(),
|
||||||
deepgramRegistration(),
|
deepgramRegistration(),
|
||||||
@@ -62,8 +63,10 @@ func NewBuiltinRegistry() *Registry {
|
|||||||
pagerdutyRegistration(),
|
pagerdutyRegistration(),
|
||||||
pylonRegistration(),
|
pylonRegistration(),
|
||||||
qoveryRegistration(),
|
qoveryRegistration(),
|
||||||
|
railwayRegistration(),
|
||||||
renderRegistration(),
|
renderRegistration(),
|
||||||
resendRegistration(),
|
resendRegistration(),
|
||||||
|
scalewayRegistration(),
|
||||||
sendgridRegistration(),
|
sendgridRegistration(),
|
||||||
sentryRegistration(),
|
sentryRegistration(),
|
||||||
signozRegistration(),
|
signozRegistration(),
|
||||||
@@ -72,6 +75,7 @@ func NewBuiltinRegistry() *Registry {
|
|||||||
tailscaleRegistration(),
|
tailscaleRegistration(),
|
||||||
tallyRegistration(),
|
tallyRegistration(),
|
||||||
vercelRegistration(),
|
vercelRegistration(),
|
||||||
|
yousignRegistration(),
|
||||||
zendeskRegistration(),
|
zendeskRegistration(),
|
||||||
} {
|
} {
|
||||||
if err := r.Register(reg); err != nil {
|
if err := r.Register(reg); err != nil {
|
||||||
|
|||||||
68
pkg/connector/provider/crisp.go
Normal file
68
pkg/connector/provider/crisp.go
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
// Copyright (c) 2026 Probo Inc <hello@probo.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 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)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -36,6 +36,7 @@ const (
|
|||||||
openRouterMembersProbeURL = "https://openrouter.ai/api/v1/organization/members?limit=1"
|
openRouterMembersProbeURL = "https://openrouter.ai/api/v1/organization/members?limit=1"
|
||||||
linearGraphQLEndpoint = "https://api.linear.app/graphql"
|
linearGraphQLEndpoint = "https://api.linear.app/graphql"
|
||||||
mondayGraphQLEndpoint = "https://api.monday.com/v2"
|
mondayGraphQLEndpoint = "https://api.monday.com/v2"
|
||||||
|
railwayGraphQLEndpoint = "https://backboard.railway.com/graphql/v2"
|
||||||
posthogOrganizationPath = "/api/organizations/@current/"
|
posthogOrganizationPath = "/api/organizations/@current/"
|
||||||
posthogUSBaseURL = "https://us.posthog.com"
|
posthogUSBaseURL = "https://us.posthog.com"
|
||||||
posthogEUBaseURL = "https://eu.posthog.com"
|
posthogEUBaseURL = "https://eu.posthog.com"
|
||||||
@@ -233,6 +234,29 @@ func buildNeonProbeURL(conn *coredata.Connector) (string, error) {
|
|||||||
return endpoint + "?" + q.Encode(), nil
|
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) {
|
func buildRenderProbeURL(conn *coredata.Connector) (string, error) {
|
||||||
s, err := coredata.ConnectorSettings[coredata.RenderConnectorSettings](conn)
|
s, err := coredata.ConnectorSettings[coredata.RenderConnectorSettings](conn)
|
||||||
if err != nil {
|
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(
|
func probeAnthropic(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
httpClient *http.Client,
|
httpClient *http.Client,
|
||||||
|
|||||||
@@ -16,7 +16,9 @@ package provider
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"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)
|
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) {
|
func TestProbeOpenRouter(t *testing.T) {
|
||||||
t.Parallel()
|
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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
44
pkg/connector/provider/railway.go
Normal file
44
pkg/connector/provider/railway.go
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
// Copyright (c) 2026 Probo Inc <hello@probo.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 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)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
59
pkg/connector/provider/scaleway.go
Normal file
59
pkg/connector/provider/scaleway.go
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
// Copyright (c) 2026 Probo Inc <hello@probo.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 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
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
48
pkg/connector/provider/yousign.go
Normal file
48
pkg/connector/provider/yousign.go
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
// Copyright (c) 2026 Probo Inc <hello@probo.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 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
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -74,6 +74,10 @@ const (
|
|||||||
ConnectorProviderOpenRouter ConnectorProvider = "OPENROUTER"
|
ConnectorProviderOpenRouter ConnectorProvider = "OPENROUTER"
|
||||||
ConnectorProviderIncidentIO ConnectorProvider = "INCIDENT_IO"
|
ConnectorProviderIncidentIO ConnectorProvider = "INCIDENT_IO"
|
||||||
ConnectorProviderBrevo ConnectorProvider = "BREVO"
|
ConnectorProviderBrevo ConnectorProvider = "BREVO"
|
||||||
|
ConnectorProviderScaleway ConnectorProvider = "SCALEWAY"
|
||||||
|
ConnectorProviderYousign ConnectorProvider = "YOUSIGN"
|
||||||
|
ConnectorProviderRailway ConnectorProvider = "RAILWAY"
|
||||||
|
ConnectorProviderCrisp ConnectorProvider = "CRISP"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -135,6 +139,10 @@ func ConnectorProviders() []ConnectorProvider {
|
|||||||
ConnectorProviderOpenRouter,
|
ConnectorProviderOpenRouter,
|
||||||
ConnectorProviderIncidentIO,
|
ConnectorProviderIncidentIO,
|
||||||
ConnectorProviderBrevo,
|
ConnectorProviderBrevo,
|
||||||
|
ConnectorProviderScaleway,
|
||||||
|
ConnectorProviderYousign,
|
||||||
|
ConnectorProviderRailway,
|
||||||
|
ConnectorProviderCrisp,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -191,7 +199,11 @@ func (v ConnectorProvider) IsValid() bool {
|
|||||||
ConnectorProviderPylon,
|
ConnectorProviderPylon,
|
||||||
ConnectorProviderOpenRouter,
|
ConnectorProviderOpenRouter,
|
||||||
ConnectorProviderIncidentIO,
|
ConnectorProviderIncidentIO,
|
||||||
ConnectorProviderBrevo:
|
ConnectorProviderBrevo,
|
||||||
|
ConnectorProviderScaleway,
|
||||||
|
ConnectorProviderYousign,
|
||||||
|
ConnectorProviderRailway,
|
||||||
|
ConnectorProviderCrisp:
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -174,6 +174,20 @@ type (
|
|||||||
LangfuseConnectorSettings struct {
|
LangfuseConnectorSettings struct {
|
||||||
BaseURL string `json:"base_url"`
|
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
|
// GrantType returns the OAuth2 grant type recorded on the connector's
|
||||||
|
|||||||
15
pkg/coredata/migrations/20260627T107898Z.sql
Normal file
15
pkg/coredata/migrations/20260627T107898Z.sql
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
-- Copyright (c) 2026 Probo Inc <hello@probo.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.
|
||||||
|
|
||||||
|
ALTER TYPE connector_provider ADD VALUE IF NOT EXISTS 'CRISP';
|
||||||
15
pkg/coredata/migrations/20260627T580764Z.sql
Normal file
15
pkg/coredata/migrations/20260627T580764Z.sql
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
-- Copyright (c) 2026 Probo Inc <hello@probo.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.
|
||||||
|
|
||||||
|
ALTER TYPE connector_provider ADD VALUE IF NOT EXISTS 'SCALEWAY';
|
||||||
15
pkg/coredata/migrations/20260627T668415Z.sql
Normal file
15
pkg/coredata/migrations/20260627T668415Z.sql
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
-- Copyright (c) 2026 Probo Inc <hello@probo.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.
|
||||||
|
|
||||||
|
ALTER TYPE connector_provider ADD VALUE IF NOT EXISTS 'RAILWAY';
|
||||||
15
pkg/coredata/migrations/20260627T820699Z.sql
Normal file
15
pkg/coredata/migrations/20260627T820699Z.sql
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
-- Copyright (c) 2026 Probo Inc <hello@probo.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.
|
||||||
|
|
||||||
|
ALTER TYPE connector_provider ADD VALUE IF NOT EXISTS 'YOUSIGN';
|
||||||
@@ -189,6 +189,18 @@ func apiKeyConnectorSettings(input types.CreateAPIKeyConnectorInput) (json.RawMe
|
|||||||
}
|
}
|
||||||
|
|
||||||
return json.Marshal(&coredata.LangfuseConnectorSettings{BaseURL: *input.LangfuseBaseURL})
|
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
|
return nil, nil
|
||||||
|
|||||||
@@ -92,6 +92,13 @@ enum ConnectorProvider
|
|||||||
value: "go.probo.inc/probo/pkg/coredata.ConnectorProviderIncidentIO"
|
value: "go.probo.inc/probo/pkg/coredata.ConnectorProviderIncidentIO"
|
||||||
)
|
)
|
||||||
BREVO @goEnum(value: "go.probo.inc/probo/pkg/coredata.ConnectorProviderBrevo")
|
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 {
|
type ConnectorProviderInfo {
|
||||||
@@ -174,6 +181,8 @@ input CreateAPIKeyConnectorInput {
|
|||||||
renderWorkspaceId: String
|
renderWorkspaceId: String
|
||||||
neonOrganizationId: String
|
neonOrganizationId: String
|
||||||
langfuseBaseUrl: String
|
langfuseBaseUrl: String
|
||||||
|
scalewayOrganizationId: String
|
||||||
|
crispWebsiteId: String
|
||||||
}
|
}
|
||||||
|
|
||||||
type CreateAPIKeyConnectorPayload {
|
type CreateAPIKeyConnectorPayload {
|
||||||
|
|||||||
Reference in New Issue
Block a user