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:
Aurélien Sibiril
2026-06-27 20:03:10 +02:00
parent 22df5742ee
commit b408aab59d
30 changed files with 2024 additions and 1 deletions

View File

@@ -1532,3 +1532,136 @@ func (r *microsoft365NameResolver) ResolveInstanceName(ctx context.Context) (str
return "", nil
}
// railwayNameResolver resolves the Railway workspace name via GraphQL, for the
// AccessReviewSource title. With a single workspace it uses that workspace's
// name; with several it falls back to the account holder's name, since the
// source spans all of the account's workspaces.
type railwayNameResolver struct {
httpClient *http.Client
}
func NewRailwayNameResolver(httpClient *http.Client) NameResolver {
return &railwayNameResolver{httpClient: httpClient}
}
func (r *railwayNameResolver) ResolveInstanceName(ctx context.Context) (string, error) {
body := struct {
Query string `json:"query"`
}{
Query: `query { me { name workspaces { id name } } }`,
}
payload, err := json.Marshal(body)
if err != nil {
return "", fmt.Errorf("cannot marshal railway account query: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, railwayGraphQLEndpoint, bytes.NewReader(payload))
if err != nil {
return "", fmt.Errorf("cannot create railway account request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
httpResp, err := r.httpClient.Do(req)
if err != nil {
return "", fmt.Errorf("cannot execute railway account request: %w", err)
}
defer func() { _ = httpResp.Body.Close() }()
// Best-effort: a non-2xx must not make the source-name worker retry forever
// — keep the generic name. (Railway also signals auth failure with a 200 +
// errors body, handled below.)
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
return "", nil
}
var resp struct {
Data struct {
Me *struct {
Name string `json:"name"`
Workspaces []struct {
Name string `json:"name"`
} `json:"workspaces"`
} `json:"me"`
} `json:"data"`
Errors []struct {
Message string `json:"message"`
} `json:"errors"`
}
if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil {
return "", fmt.Errorf("cannot decode railway account response: %w", err)
}
if len(resp.Errors) > 0 || resp.Data.Me == nil {
return "", nil
}
// A single workspace names the source directly; with several (or none) fall
// back to the account holder's display name. Never the email — a terminal
// empty result keeps the generic source name, which the worker tolerates.
me := resp.Data.Me
if len(me.Workspaces) == 1 {
return me.Workspaces[0].Name, nil
}
return me.Name, nil
}
// crispNameResolver resolves the Crisp website name via GET /v1/website/{id},
// for the AccessReviewSource title. Like the driver it sends the X-Crisp-Tier
// header; the Basic credential is supplied by the connection transport.
type crispNameResolver struct {
httpClient *http.Client
websiteID string
}
func NewCrispNameResolver(httpClient *http.Client, websiteID string) NameResolver {
return &crispNameResolver{httpClient: httpClient, websiteID: websiteID}
}
func (r *crispNameResolver) ResolveInstanceName(ctx context.Context) (string, error) {
if r.websiteID == "" {
return "", nil
}
endpoint, err := url.JoinPath(crispAPIBaseURL, "website", url.PathEscape(r.websiteID))
if err != nil {
return "", fmt.Errorf("cannot build crisp website URL: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return "", fmt.Errorf("cannot create crisp website request: %w", err)
}
req.Header.Set("Accept", "application/json")
req.Header.Set(crispTierHeader, crispTierValue)
httpResp, err := r.httpClient.Do(req)
if err != nil {
return "", fmt.Errorf("cannot execute crisp website request: %w", err)
}
defer func() { _ = httpResp.Body.Close() }()
// Best-effort: a non-2xx (revoked token, stale website id) must not make the
// source-name worker retry forever — keep the generic name.
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
return "", nil
}
var resp struct {
Data struct {
Name string `json:"name"`
} `json:"data"`
}
if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil {
return "", fmt.Errorf("cannot decode crisp website response: %w", err)
}
return resp.Data.Name, nil
}