diff --git a/pkg/accessreview/drivers/crisp.go b/pkg/accessreview/drivers/crisp.go index e4f3a26b3..3b4e2ee2c 100644 --- a/pkg/accessreview/drivers/crisp.go +++ b/pkg/accessreview/drivers/crisp.go @@ -96,22 +96,9 @@ func NewCrispDriver(httpClient *http.Client, websiteID string) *CrispDriver { } func (d *CrispDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error) { - endpoint, err := url.JoinPath(crispAPIBaseURL, "website", url.PathEscape(d.websiteID), "operators", "list") + httpResp, err := crispGet(ctx, d.httpClient, "operators", "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) + return nil, err } defer func() { _ = httpResp.Body.Close() }() @@ -138,9 +125,9 @@ func (d *CrispDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error) records = append(records, AccountRecord{ Email: email, FullName: crispFullName(details, email), - Roles: crispRoles(details.Role), + Roles: ownerMemberRoles(details.Role), JobTitle: strings.TrimSpace(details.Title), - IsAdmin: crispIsAdmin(details.Role), + IsAdmin: isOwnerRole(details.Role), MFAStatus: coredata.MFAStatusUnknown, AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown, AccountType: coredata.AccessReviewEntryAccountTypeUser, @@ -164,28 +151,17 @@ func GetCrispSubscriptionSettings( websiteID string, pluginID string, ) (*CrispSubscriptionSettings, error) { - endpoint, err := url.JoinPath( - crispAPIBaseURL, + httpResp, err := crispGet( + ctx, + httpClient, + "subscription settings", "plugins", "subscription", url.PathEscape(websiteID), url.PathEscape(pluginID), "settings", ) if err != nil { - return nil, fmt.Errorf("cannot build crisp subscription settings URL: %w", err) - } - - req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) - if err != nil { - return nil, fmt.Errorf("cannot create crisp subscription settings request: %w", err) - } - - req.Header.Set("Accept", "application/json") - req.Header.Set(crispTierHeader, crispTierValue) - - httpResp, err := httpClient.Do(req) - if err != nil { - return nil, fmt.Errorf("cannot execute crisp subscription settings request: %w", err) + return nil, err } defer func() { _ = httpResp.Body.Close() }() @@ -210,6 +186,33 @@ func GetCrispSubscriptionSettings( return &resp.Data.Settings, nil } +// crispGet issues an authenticated GET against the Crisp API for the given path +// segments (joined onto crispAPIBaseURL), setting the Accept and X-Crisp-Tier +// headers every Crisp request needs; the Basic plugin credential is attached by +// the connection transport. The caller owns status-code handling and must close +// the returned response body. label names the request in wrapped errors. +func crispGet(ctx context.Context, httpClient *http.Client, label string, path ...string) (*http.Response, error) { + endpoint, err := url.JoinPath(crispAPIBaseURL, path...) + if err != nil { + return nil, fmt.Errorf("cannot build crisp %s URL: %w", label, err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, fmt.Errorf("cannot create crisp %s request: %w", label, err) + } + + req.Header.Set("Accept", "application/json") + req.Header.Set(crispTierHeader, crispTierValue) + + httpResp, err := httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("cannot execute crisp %s request: %w", label, err) + } + + return httpResp, nil +} + func crispFullName(details crispOperatorDetails, fallback string) string { if name := strings.TrimSpace(details.FirstName + " " + details.LastName); name != "" { return name @@ -217,27 +220,3 @@ func crispFullName(details crispOperatorDetails, fallback string) string { 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{} - } -} - -// crispIsAdmin reports whether a Crisp operator role grants administrative -// access. Only the website owner is an administrator; members are not. -func crispIsAdmin(role string) bool { - return strings.EqualFold(strings.TrimSpace(role), "owner") -} diff --git a/pkg/accessreview/drivers/driver.go b/pkg/accessreview/drivers/driver.go index 134bd03fe..117a5ccf8 100644 --- a/pkg/accessreview/drivers/driver.go +++ b/pkg/accessreview/drivers/driver.go @@ -106,3 +106,28 @@ func activeFromStatus(status string) *bool { return &inactive } } + +// ownerMemberRoles maps a provider role to a display label for providers whose +// role model is exactly owner/member (e.g. Crisp operators, Scaleway org user +// types): "owner" → Owner, "member" → Member. An unknown future value is passed +// through verbatim and no role yields an empty slice. +func ownerMemberRoles(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{} + } +} + +// isOwnerRole reports whether a provider role is the owner, the only role in the +// owner/member model that grants administrative access. +func isOwnerRole(role string) bool { + return strings.EqualFold(strings.TrimSpace(role), "owner") +} diff --git a/pkg/accessreview/drivers/name_resolver.go b/pkg/accessreview/drivers/name_resolver.go index e37b0ba85..4541468a3 100644 --- a/pkg/accessreview/drivers/name_resolver.go +++ b/pkg/accessreview/drivers/name_resolver.go @@ -1546,28 +1546,9 @@ func NewRailwayNameResolver(httpClient *http.Client) NameResolver { } 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) + httpResp, err := railwayPost(ctx, r.httpClient, "account", `query { me { name workspaces { id name } } }`) 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) + return "", err } defer func() { _ = httpResp.Body.Close() }() @@ -1628,22 +1609,9 @@ func (r *crispNameResolver) ResolveInstanceName(ctx context.Context) (string, er return "", nil } - endpoint, err := url.JoinPath(crispAPIBaseURL, "website", url.PathEscape(r.websiteID)) + httpResp, err := crispGet(ctx, r.httpClient, "website", "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) + return "", err } defer func() { _ = httpResp.Body.Close() }() diff --git a/pkg/accessreview/drivers/railway.go b/pkg/accessreview/drivers/railway.go index 13092f232..a27e435cd 100644 --- a/pkg/accessreview/drivers/railway.go +++ b/pkg/accessreview/drivers/railway.go @@ -189,28 +189,9 @@ func railwayRecords(me *railwayMe) []AccountRecord { } func (d *RailwayDriver) queryMe(ctx context.Context) (*railwayMe, error) { - body := struct { - Query string `json:"query"` - }{ - Query: railwayMembersQuery, - } - - payload, err := json.Marshal(body) + httpResp, err := railwayPost(ctx, d.httpClient, "members", railwayMembersQuery) 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) + return nil, err } defer func() { _ = httpResp.Body.Close() }() @@ -238,6 +219,38 @@ func (d *RailwayDriver) queryMe(ctx context.Context) (*railwayMe, error) { return resp.Data.Me, nil } +// railwayPost issues a GraphQL POST carrying query to Railway's endpoint, +// setting the Content-Type and Accept headers; the Bearer credential is attached +// by the connection transport. The caller owns status handling and must close +// the returned response body. label names the request in wrapped errors. +func railwayPost(ctx context.Context, httpClient *http.Client, label, query string) (*http.Response, error) { + body := struct { + Query string `json:"query"` + }{ + Query: query, + } + + payload, err := json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("cannot marshal railway %s query: %w", label, err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, railwayGraphQLEndpoint, bytes.NewReader(payload)) + if err != nil { + return nil, fmt.Errorf("cannot create railway %s request: %w", label, err) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + + httpResp, err := httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("cannot execute railway %s request: %w", label, err) + } + + return httpResp, nil +} + func railwayFullName(m railwayMember, fallback string) string { if name := strings.TrimSpace(m.Name); name != "" { return name diff --git a/pkg/accessreview/drivers/scaleway.go b/pkg/accessreview/drivers/scaleway.go index 2d94df465..147c1a1df 100644 --- a/pkg/accessreview/drivers/scaleway.go +++ b/pkg/accessreview/drivers/scaleway.go @@ -96,9 +96,9 @@ func (d *ScalewayDriver) ListAccounts(ctx context.Context) ([]AccountRecord, err record := AccountRecord{ Email: email, FullName: scalewayFullName(u, email), - Roles: scalewayRoles(u.Type), + Roles: ownerMemberRoles(u.Type), Active: scalewayActive(u.Status, u.Locked), - IsAdmin: scalewayIsAdmin(u.Type), + IsAdmin: isOwnerRole(u.Type), MFAStatus: scalewayMFAStatus(u), AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown, AccountType: coredata.AccessReviewEntryAccountTypeUser, @@ -176,30 +176,6 @@ func scalewayFullName(u scalewayUser, fallback string) string { 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{} - } -} - -// scalewayIsAdmin reports whether a Scaleway user type grants administrative -// access. Only the organization owner is an administrator; members are not. -func scalewayIsAdmin(userType string) bool { - return strings.EqualFold(strings.TrimSpace(userType), "owner") -} - // 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 diff --git a/pkg/connector/provider/probe.go b/pkg/connector/provider/probe.go index b14778055..20b682bd9 100644 --- a/pkg/connector/provider/probe.go +++ b/pkg/connector/provider/probe.go @@ -40,6 +40,9 @@ const ( posthogOrganizationPath = "/api/organizations/@current/" posthogUSBaseURL = "https://us.posthog.com" posthogEUBaseURL = "https://eu.posthog.com" + crispAPIBaseURL = "https://api.crisp.chat/v1" + crispTierHeader = "X-Crisp-Tier" + crispTierValue = "plugin" ) // ProbeConnection verifies that the connector credential is accepted by the @@ -505,7 +508,7 @@ func probeCrisp( return fmt.Errorf("missing crisp website_id") } - endpoint, err := url.JoinPath("https://api.crisp.chat/v1", "website", url.PathEscape(s.WebsiteID), "operators", "list") + endpoint, err := url.JoinPath(crispAPIBaseURL, "website", url.PathEscape(s.WebsiteID), "operators", "list") if err != nil { return fmt.Errorf("cannot build crisp probe URL: %w", err) } @@ -516,7 +519,7 @@ func probeCrisp( } req.Header.Set("Accept", "application/json") - req.Header.Set("X-Crisp-Tier", "plugin") + req.Header.Set(crispTierHeader, crispTierValue) return doProbeRequest(httpClient, req, http.StatusNotFound) }