From 7a43acd3c2b76d35430e3ec18a6735ad0167d126 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 29 May 2026 03:44:39 +0000 Subject: [PATCH] Add Qovery access review driver support Register Qovery as a connector provider and add a new access review driver that fetches organization members from the Qovery API. Extend API key connection handling with a configurable Authorization token scheme so Qovery can use "Token" while existing providers continue to default to Bearer. Signed-off-by: Cursor Agent Co-authored-by: Bryan FRIMIN --- .../dialogs/AddAccessSourceDialog.tsx | 3 + packages/ui/src/Atoms/ThirdParties/Qovery.tsx | 16 ++ .../src/Atoms/ThirdParties/ThirdPartyLogo.tsx | 2 + packages/ui/src/Atoms/ThirdParties/index.ts | 1 + pkg/accessreview/drivers/name_resolver.go | 54 ++++++ .../drivers/name_resolver_test.go | 70 ++++++++ pkg/accessreview/drivers/qovery.go | 164 ++++++++++++++++++ pkg/accessreview/drivers/qovery_test.go | 119 +++++++++++++ pkg/accessreview/drivers/testdata/qovery.yaml | 40 +++++ pkg/connector/provider/builtin.go | 1 + pkg/connector/provider/qovery.go | 58 +++++++ pkg/connector/provider/qovery_test.go | 106 +++++++++++ pkg/coredata/connector_provider.go | 5 +- pkg/coredata/connector_settings.go | 4 + pkg/coredata/migrations/20260608T617248Z.sql | 15 ++ .../api/console/v1/connector_settings.go | 6 + .../api/console/v1/graphql/connector.graphql | 2 + 17 files changed, 665 insertions(+), 1 deletion(-) create mode 100644 packages/ui/src/Atoms/ThirdParties/Qovery.tsx create mode 100644 pkg/accessreview/drivers/qovery.go create mode 100644 pkg/accessreview/drivers/qovery_test.go create mode 100644 pkg/accessreview/drivers/testdata/qovery.yaml create mode 100644 pkg/connector/provider/qovery.go create mode 100644 pkg/connector/provider/qovery_test.go create mode 100644 pkg/coredata/migrations/20260608T617248Z.sql diff --git a/apps/console/src/pages/organizations/access-reviews/dialogs/AddAccessSourceDialog.tsx b/apps/console/src/pages/organizations/access-reviews/dialogs/AddAccessSourceDialog.tsx index c60291ae7..501cbe115 100644 --- a/apps/console/src/pages/organizations/access-reviews/dialogs/AddAccessSourceDialog.tsx +++ b/apps/console/src/pages/organizations/access-reviews/dialogs/AddAccessSourceDialog.tsx @@ -151,6 +151,9 @@ function mapAPIKeyExtraSettingToField( case "BETTER_STACK": if (settingKey === "teamName") return "betterStackTeamName"; break; + case "QOVERY": + if (settingKey === "organizationId") return "qoveryOrganizationId"; + break; } return null; } diff --git a/packages/ui/src/Atoms/ThirdParties/Qovery.tsx b/packages/ui/src/Atoms/ThirdParties/Qovery.tsx new file mode 100644 index 000000000..33b77d0eb --- /dev/null +++ b/packages/ui/src/Atoms/ThirdParties/Qovery.tsx @@ -0,0 +1,16 @@ +import type { ComponentProps } from "react"; + +export function Qovery(props: ComponentProps<"svg">) { + return ( + + + + ); +} diff --git a/packages/ui/src/Atoms/ThirdParties/ThirdPartyLogo.tsx b/packages/ui/src/Atoms/ThirdParties/ThirdPartyLogo.tsx index a22681ac5..913a708b2 100644 --- a/packages/ui/src/Atoms/ThirdParties/ThirdPartyLogo.tsx +++ b/packages/ui/src/Atoms/ThirdParties/ThirdPartyLogo.tsx @@ -44,6 +44,7 @@ import { OnePassword } from "./OnePassword"; import { OpenAI } from "./OpenAI"; import { PagerDuty } from "./PagerDuty"; import { PostHog } from "./PostHog"; +import { Qovery } from "./Qovery"; import { Resend } from "./Resend"; import { SendGrid } from "./SendGrid"; import { Sentry } from "./Sentry"; @@ -89,6 +90,7 @@ const thirdParties: Record>> = { OPENAI: OpenAI, PAGERDUTY: PagerDuty, POSTHOG: PostHog, + QOVERY: Qovery, RESEND: Resend, SENDGRID: SendGrid, SENTRY: Sentry, diff --git a/packages/ui/src/Atoms/ThirdParties/index.ts b/packages/ui/src/Atoms/ThirdParties/index.ts index ba7c824da..c19dfdde1 100644 --- a/packages/ui/src/Atoms/ThirdParties/index.ts +++ b/packages/ui/src/Atoms/ThirdParties/index.ts @@ -28,6 +28,7 @@ export { OnePassword } from "./OnePassword"; export { OpenAI } from "./OpenAI"; export { PagerDuty } from "./PagerDuty"; export { PostHog } from "./PostHog"; +export { Qovery } from "./Qovery"; export { Resend } from "./Resend"; export { SendGrid } from "./SendGrid"; export { Sentry } from "./Sentry"; diff --git a/pkg/accessreview/drivers/name_resolver.go b/pkg/accessreview/drivers/name_resolver.go index df34050c4..f5f08cd97 100644 --- a/pkg/accessreview/drivers/name_resolver.go +++ b/pkg/accessreview/drivers/name_resolver.go @@ -298,6 +298,60 @@ func (r *tallyNameResolver) ResolveInstanceName(ctx context.Context) (string, er return resp.Name, nil } +// qoveryNameResolver resolves the Qovery organization name. +type qoveryNameResolver struct { + httpClient *http.Client + organizationID string +} + +func NewQoveryNameResolver(httpClient *http.Client, organizationID string) NameResolver { + return &qoveryNameResolver{ + httpClient: httpClient, + organizationID: organizationID, + } +} + +func (r *qoveryNameResolver) ResolveInstanceName(ctx context.Context) (string, error) { + if r.organizationID == "" { + return "", nil + } + + endpoint, err := url.JoinPath(qoveryAPIBaseURL, "organization", url.PathEscape(r.organizationID)) + if err != nil { + return "", fmt.Errorf("cannot build qovery organization URL: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return "", fmt.Errorf("cannot create qovery organization request: %w", err) + } + + req.Header.Set("Accept", "application/json") + + httpResp, err := r.httpClient.Do(req) + if err != nil { + return "", fmt.Errorf("cannot execute qovery organization request: %w", err) + } + + defer func() { _ = httpResp.Body.Close() }() + + // Best-effort: a non-2xx (revoked token, deleted org, stale ID) must not + // make the source-name worker retry forever. Give up gracefully and keep + // the generic source name; a dead token surfaces on the next ListAccounts. + if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 { + return "", nil + } + + var resp struct { + Name string `json:"name"` + } + if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil { + return "", fmt.Errorf("cannot decode qovery organization response: %w", err) + } + + return resp.Name, nil +} + // hubspotNameResolver resolves the HubSpot account name. type hubspotNameResolver struct { httpClient *http.Client diff --git a/pkg/accessreview/drivers/name_resolver_test.go b/pkg/accessreview/drivers/name_resolver_test.go index 4d5338d55..a52b222df 100644 --- a/pkg/accessreview/drivers/name_resolver_test.go +++ b/pkg/accessreview/drivers/name_resolver_test.go @@ -185,6 +185,76 @@ func TestSentryNameResolver(t *testing.T) { } } +func TestQoveryNameResolver(t *testing.T) { + t.Parallel() + + t.Run("empty organization id returns nothing without HTTP call", func(t *testing.T) { + t.Parallel() + + client := &http.Client{Transport: roundTripperFunc(func(*http.Request) (*http.Response, error) { + t.Fatalf("resolver should not make an HTTP call for an empty organization id") + return nil, nil + })} + + got, err := NewQoveryNameResolver(client, "").ResolveInstanceName(context.Background()) + require.NoError(t, err) + assert.Empty(t, got) + }) + + cases := []struct { + name string + status int + body string + want string + }{ + { + name: "200 returns name", + status: http.StatusOK, + body: `{"id":"26ac87db-ae79-4be4-bd33-7f839f0e1647","name":"Acme Inc"}`, + want: "Acme Inc", + }, + { + name: "401 is terminal (no error, no name)", + status: http.StatusUnauthorized, + body: `{"error":"unauthorized"}`, + want: "", + }, + { + name: "404 is terminal (no error, no name)", + status: http.StatusNotFound, + body: `{"error":"not found"}`, + want: "", + }, + { + name: "500 is terminal (no error, no name)", + status: http.StatusInternalServerError, + body: `{"error":"boom"}`, + want: "", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method) + assert.Equal(t, "/organization/26ac87db-ae79-4be4-bd33-7f839f0e1647", r.URL.Path) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(tc.status) + _, _ = w.Write([]byte(tc.body)) + })) + defer srv.Close() + + client := &http.Client{Transport: &hostRewriter{target: srv.URL}} + + got, err := NewQoveryNameResolver(client, "26ac87db-ae79-4be4-bd33-7f839f0e1647").ResolveInstanceName(context.Background()) + require.NoError(t, err) + assert.Equal(t, tc.want, got) + }) + } +} + func TestTailscaleNameResolver(t *testing.T) { t.Parallel() diff --git a/pkg/accessreview/drivers/qovery.go b/pkg/accessreview/drivers/qovery.go new file mode 100644 index 000000000..4e746d7e1 --- /dev/null +++ b/pkg/accessreview/drivers/qovery.go @@ -0,0 +1,164 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package drivers + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "strings" + "time" + + "go.probo.inc/probo/pkg/coredata" +) + +const qoveryAPIBaseURL = "https://api.qovery.com" + +type QoveryDriver struct { + httpClient *http.Client + organizationID string +} + +var _ Driver = (*QoveryDriver)(nil) + +type qoveryMembersResponse struct { + Results []qoveryMember `json:"results"` +} + +type qoveryMember struct { + ID string `json:"id"` + Name string `json:"name"` + Nickname string `json:"nickname"` + Email string `json:"email"` + LastActivityAt string `json:"last_activity_at"` + CreatedAt string `json:"created_at"` + Role string `json:"role"` +} + +func NewQoveryDriver(httpClient *http.Client, organizationID string) *QoveryDriver { + return &QoveryDriver{ + httpClient: httpClient, + organizationID: organizationID, + } +} + +func (d *QoveryDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error) { + endpoint, err := url.JoinPath( + qoveryAPIBaseURL, + "organization", + url.PathEscape(d.organizationID), + "member", + ) + if err != nil { + return nil, fmt.Errorf("cannot build qovery members URL: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, fmt.Errorf("cannot create qovery members request: %w", err) + } + + req.Header.Set("Accept", "application/json") + + httpResp, err := d.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("cannot execute qovery members request: %w", err) + } + + defer func() { + _ = httpResp.Body.Close() + }() + + if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 { + return nil, fmt.Errorf("cannot fetch qovery members: unexpected status %d", httpResp.StatusCode) + } + + var resp qoveryMembersResponse + if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil { + return nil, fmt.Errorf("cannot decode qovery members response: %w", err) + } + + records := make([]AccountRecord, 0, len(resp.Results)) + for _, member := range resp.Results { + if member.Email == "" { + continue + } + + record := AccountRecord{ + Email: member.Email, + FullName: qoveryFullName(member), + Role: qoveryRole(member.Role), + IsAdmin: qoveryIsAdmin(member.Role), + MFAStatus: coredata.MFAStatusUnknown, + AuthMethod: coredata.AccessEntryAuthMethodUnknown, + AccountType: coredata.AccessEntryAccountTypeUser, + ExternalID: member.ID, + } + + if member.LastActivityAt != "" { + if t, err := time.Parse(time.RFC3339, member.LastActivityAt); err == nil { + record.LastLogin = &t + } + } + + if member.CreatedAt != "" { + if t, err := time.Parse(time.RFC3339, member.CreatedAt); err == nil { + record.CreatedAt = &t + } + } + + records = append(records, record) + } + + return records, nil +} + +func qoveryFullName(member qoveryMember) string { + if member.Name != "" { + return member.Name + } + + if member.Nickname != "" { + return member.Nickname + } + + return member.Email +} + +func qoveryRole(role string) string { + switch strings.ToUpper(role) { + case "OWNER": + return "Owner" + case "ADMIN": + return "Admin" + case "DEVELOPER": + return "Developer" + case "VIEWER": + return "Viewer" + default: + return role + } +} + +func qoveryIsAdmin(role string) bool { + switch strings.ToUpper(role) { + case "OWNER", "ADMIN": + return true + default: + return false + } +} diff --git a/pkg/accessreview/drivers/qovery_test.go b/pkg/accessreview/drivers/qovery_test.go new file mode 100644 index 000000000..c9abfa006 --- /dev/null +++ b/pkg/accessreview/drivers/qovery_test.go @@ -0,0 +1,119 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package drivers + +import ( + "context" + "io" + "net/http" + "os" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestQoveryDriverListAccounts(t *testing.T) { + t.Parallel() + + rec := newRecorder(t, "testdata/qovery", "QOVERY_API_TOKEN") + + authValue := "" + if token := os.Getenv("QOVERY_API_TOKEN"); token != "" { + authValue = "Token " + token + } + + client := newVCRClient(rec, authValue) + + orgID := os.Getenv("QOVERY_ORG_ID") + if orgID == "" { + orgID = "11111111-2222-3333-4444-555555555555" + } + + driver := NewQoveryDriver(client, orgID) + records, err := driver.ListAccounts(context.Background()) + require.NoError(t, err) + + // Three members in the cassette; the third has no email and is dropped. + require.Len(t, records, 2) + + // Owner: built-in OWNER role → admin; name + both timestamps populated. + // Qovery member IDs are the IdP subject (e.g. "google-oauth2|"). + assert.Equal(t, "jane.doe@example.com", records[0].Email) + assert.Equal(t, "Jane Doe", records[0].FullName) + assert.Equal(t, "Owner", records[0].Role) + assert.True(t, records[0].IsAdmin) + assert.Equal(t, "google-oauth2|100000000000000000001", records[0].ExternalID) + require.NotNil(t, records[0].LastLogin) + require.NotNil(t, records[0].CreatedAt) + assert.Nil(t, records[0].Active) + + // Developer: empty name falls back to nickname; null last_activity_at + // leaves LastLogin nil. + assert.Equal(t, "john.smith@example.com", records[1].Email) + assert.Equal(t, "john", records[1].FullName) + assert.Equal(t, "Developer", records[1].Role) + assert.False(t, records[1].IsAdmin) + assert.Equal(t, "google-oauth2|100000000000000000002", records[1].ExternalID) + assert.Nil(t, records[1].LastLogin) + require.NotNil(t, records[1].CreatedAt) +} + +func TestQoveryDriverListAccountsError(t *testing.T) { + t.Parallel() + + client := &http.Client{ + Transport: roundTripFunc( + func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusUnauthorized, + Body: io.NopCloser(strings.NewReader(`{"error":"unauthorized"}`)), + Header: make(http.Header), + }, nil + }, + ), + } + + driver := NewQoveryDriver(client, "26ac87db-ae79-4be4-bd33-7f839f0e1647") + _, err := driver.ListAccounts(context.Background()) + require.Error(t, err) + assert.Contains(t, err.Error(), "unexpected status 401") +} + +func TestQoveryRole(t *testing.T) { + t.Parallel() + + cases := []struct { + in string + want string + isAdmin bool + }{ + {in: "OWNER", want: "Owner", isAdmin: true}, + {in: "ADMIN", want: "Admin", isAdmin: true}, + {in: "DEVELOPER", want: "Developer", isAdmin: false}, + {in: "VIEWER", want: "Viewer", isAdmin: false}, + {in: "future_role", want: "future_role", isAdmin: false}, + } + + for _, c := range cases { + t.Run(c.in, func(t *testing.T) { + t.Parallel() + + assert.Equal(t, c.want, qoveryRole(c.in)) + assert.Equal(t, c.isAdmin, qoveryIsAdmin(c.in)) + }) + } +} diff --git a/pkg/accessreview/drivers/testdata/qovery.yaml b/pkg/accessreview/drivers/testdata/qovery.yaml new file mode 100644 index 000000000..437d1d0bd --- /dev/null +++ b/pkg/accessreview/drivers/testdata/qovery.yaml @@ -0,0 +1,40 @@ +--- +# Anonymized from a real GET /organization/{id}/member recording against a +# Qovery organization (token stripped by the recorder). Real PII (org ID, +# member IdP subject IDs, names, emails, avatar URL, role IDs) replaced with +# synthetic values; two extra members were added to keep coverage (nickname +# fallback when name is empty, and an emailless member that is dropped). The +# member object shape (id, created_at/updated_at/last_activity_at, name, +# nickname, email, role, role_name, role_id, invitation_status) mirrors the +# live response, including the "google-oauth2|" IdP-subject id format. +version: 2 +interactions: + - id: 0 + request: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + content_length: 0 + host: api.qovery.com + headers: + Accept: + - application/json + url: https://api.qovery.com/organization/11111111-2222-3333-4444-555555555555/member + method: GET + response: + proto: HTTP/2.0 + proto_major: 2 + proto_minor: 0 + content_length: -1 + uncompressed: true + body: '{"results":[{"id":"google-oauth2|100000000000000000001","created_at":"2025-01-04T12:00:00.000Z","updated_at":"2026-02-18T09:03:11.000Z","last_activity_at":"2026-02-18T09:03:11.000Z","name":"Jane Doe","nickname":"jane","profile_picture_url":"https://example.com/avatar/jane.png","email":"jane.doe@example.com","role":"OWNER","role_name":"Owner","role_id":"22222222-2222-2222-2222-222222222222","invitation_status":"ACCEPTED"},{"id":"google-oauth2|100000000000000000002","created_at":"2025-03-10T08:15:00.000Z","updated_at":"2025-03-10T08:15:00.000Z","name":"","nickname":"john","profile_picture_url":null,"email":"john.smith@example.com","role":"DEVELOPER","role_name":"Developer","role_id":"33333333-3333-3333-3333-333333333333","invitation_status":"ACCEPTED"},{"id":"google-oauth2|100000000000000000003","created_at":"2025-04-01T10:00:00.000Z","updated_at":"2025-04-01T10:00:00.000Z","name":"No Email","nickname":"noemail","profile_picture_url":null,"email":"","role":"VIEWER","role_name":"Viewer","role_id":"44444444-4444-4444-4444-444444444444","invitation_status":"PENDING"}]}' + headers: + Content-Type: + - application/json + Strict-Transport-Security: + - max-age=31536000; includeSubDomains + Vary: + - Accept-Encoding + status: 200 OK + code: 200 + duration: 563.650542ms diff --git a/pkg/connector/provider/builtin.go b/pkg/connector/provider/builtin.go index 7092c7919..3fa439dde 100644 --- a/pkg/connector/provider/builtin.go +++ b/pkg/connector/provider/builtin.go @@ -51,6 +51,7 @@ func NewBuiltinRegistry() *Registry { openaiRegistration(), posthogRegistration(), pagerdutyRegistration(), + qoveryRegistration(), resendRegistration(), sendgridRegistration(), sentryRegistration(), diff --git a/pkg/connector/provider/qovery.go b/pkg/connector/provider/qovery.go new file mode 100644 index 000000000..f60df313a --- /dev/null +++ b/pkg/connector/provider/qovery.go @@ -0,0 +1,58 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package provider + +import ( + "context" + "fmt" + "net/http" + + "go.gearno.de/kit/log" + "go.probo.inc/probo/pkg/accessreview/drivers" + "go.probo.inc/probo/pkg/coredata" +) + +func qoveryRegistration() *Registration { + return &Registration{ + Provider: coredata.ConnectorProviderQovery, + DisplayName: "Qovery", + SupportsAPIKey: true, + APIKeyAuthScheme: "Token", + ExtraSettings: []ExtraSetting{ + {Key: "organizationId", Label: "Organization ID", Required: true}, + }, + NewDriver: func(_ context.Context, c *http.Client, conn *coredata.Connector, _ *log.Logger) (drivers.Driver, error) { + s, err := coredata.ConnectorSettings[coredata.QoveryConnectorSettings](conn) + if err != nil { + return nil, fmt.Errorf("cannot read qovery connector settings: %w", err) + } + + if s.OrganizationID == "" { + return nil, fmt.Errorf("cannot create qovery driver: organization_id is required") + } + + return drivers.NewQoveryDriver(c, s.OrganizationID), nil + }, + NewNameResolver: func(ctx context.Context, c *http.Client, conn *coredata.Connector, logger *log.Logger) drivers.NameResolver { + s, err := coredata.ConnectorSettings[coredata.QoveryConnectorSettings](conn) + if err != nil { + logger.ErrorCtx(ctx, "cannot read qovery connector settings", log.Error(err)) + return nil + } + + return drivers.NewQoveryNameResolver(c, s.OrganizationID) + }, + } +} diff --git a/pkg/connector/provider/qovery_test.go b/pkg/connector/provider/qovery_test.go new file mode 100644 index 000000000..4acbd9f24 --- /dev/null +++ b/pkg/connector/provider/qovery_test.go @@ -0,0 +1,106 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package provider_test + +import ( + "context" + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.gearno.de/kit/httpclient" + "go.probo.inc/probo/pkg/accessreview/drivers" + "go.probo.inc/probo/pkg/connector/provider" + "go.probo.inc/probo/pkg/coredata" +) + +func TestQoveryRegistrationMetadata(t *testing.T) { + t.Parallel() + + r := provider.NewBuiltinRegistry() + reg, ok := r.Get(coredata.ConnectorProviderQovery) + require.True(t, ok, "qovery provider must be registered") + + assert.Equal(t, "Qovery", reg.DisplayName) + assert.True(t, reg.SupportsAPIKey) + assert.Equal(t, "Token", reg.APIKeyAuthScheme) + require.Len(t, reg.ExtraSettings, 1) + assert.Equal(t, "organizationId", reg.ExtraSettings[0].Key) + assert.Equal(t, "Organization ID", reg.ExtraSettings[0].Label) + assert.True(t, reg.ExtraSettings[0].Required) +} + +func TestQoveryNewDriver(t *testing.T) { + t.Parallel() + + r := provider.NewBuiltinRegistry() + reg, ok := r.Get(coredata.ConnectorProviderQovery) + require.True(t, ok, "qovery provider must be registered") + require.NotNil(t, reg.NewDriver, "qovery NewDriver closure must be wired") + + t.Run("creates driver with valid organization_id", func(t *testing.T) { + t.Parallel() + + raw, err := json.Marshal(&coredata.QoveryConnectorSettings{ + OrganizationID: "c4f2de4d-3e50-4f98-bf00-065778f7f5b5", + }) + require.NoError(t, err) + + conn := &coredata.Connector{ + Provider: coredata.ConnectorProviderQovery, + RawSettings: raw, + } + + drv, err := reg.NewDriver(context.Background(), httpclient.DefaultClient(httpclient.WithSSRFProtection()), conn, nil) + require.NoError(t, err) + assert.IsType(t, &drivers.QoveryDriver{}, drv) + }) + + t.Run("errors when organization_id is missing", func(t *testing.T) { + t.Parallel() + + conn := &coredata.Connector{ + Provider: coredata.ConnectorProviderQovery, + RawSettings: []byte(`{}`), + } + + _, err := reg.NewDriver(context.Background(), httpclient.DefaultClient(httpclient.WithSSRFProtection()), conn, nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "organization_id is required") + }) +} + +func TestQoveryNewNameResolver(t *testing.T) { + t.Parallel() + + r := provider.NewBuiltinRegistry() + reg, ok := r.Get(coredata.ConnectorProviderQovery) + require.True(t, ok, "qovery provider must be registered") + require.NotNil(t, reg.NewNameResolver, "qovery NewNameResolver closure must be wired") + + raw, err := json.Marshal(&coredata.QoveryConnectorSettings{ + OrganizationID: "c4f2de4d-3e50-4f98-bf00-065778f7f5b5", + }) + require.NoError(t, err) + + conn := &coredata.Connector{ + Provider: coredata.ConnectorProviderQovery, + RawSettings: raw, + } + + resolver := reg.NewNameResolver(context.Background(), httpclient.DefaultClient(httpclient.WithSSRFProtection()), conn, nil) + require.NotNil(t, resolver, "qovery name resolver must be constructed for a valid connector") +} diff --git a/pkg/coredata/connector_provider.go b/pkg/coredata/connector_provider.go index 354e4c183..b0c76c2ce 100644 --- a/pkg/coredata/connector_provider.go +++ b/pkg/coredata/connector_provider.go @@ -62,6 +62,7 @@ const ( ConnectorProviderDatadog ConnectorProvider = "DATADOG" ConnectorProviderOkta ConnectorProvider = "OKTA" ConnectorProviderZendesk ConnectorProvider = "ZENDESK" + ConnectorProviderQovery ConnectorProvider = "QOVERY" ) var ( @@ -111,6 +112,7 @@ func ConnectorProviders() []ConnectorProvider { ConnectorProviderDatadog, ConnectorProviderOkta, ConnectorProviderZendesk, + ConnectorProviderQovery, } } @@ -155,7 +157,8 @@ func (v ConnectorProvider) IsValid() bool { ConnectorProviderCursor, ConnectorProviderDatadog, ConnectorProviderOkta, - ConnectorProviderZendesk: + ConnectorProviderZendesk, + ConnectorProviderQovery: return true } diff --git a/pkg/coredata/connector_settings.go b/pkg/coredata/connector_settings.go index 3c2d34ab7..205e0d2f0 100644 --- a/pkg/coredata/connector_settings.go +++ b/pkg/coredata/connector_settings.go @@ -140,6 +140,10 @@ type ( BetterStackConnectorSettings struct { TeamName string `json:"team_name"` } + + QoveryConnectorSettings struct { + OrganizationID string `json:"organization_id"` + } ) // GrantType returns the OAuth2 grant type recorded on the connector's diff --git a/pkg/coredata/migrations/20260608T617248Z.sql b/pkg/coredata/migrations/20260608T617248Z.sql new file mode 100644 index 000000000..a84bfeb7d --- /dev/null +++ b/pkg/coredata/migrations/20260608T617248Z.sql @@ -0,0 +1,15 @@ +-- Copyright (c) 2026 Probo Inc . +-- +-- Permission to use, copy, modify, and/or distribute this software for any +-- purpose with or without fee is hereby granted, provided that the above +-- copyright notice and this permission notice appear in all copies. +-- +-- THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +-- REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +-- AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +-- INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +-- LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +-- OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +-- PERFORMANCE OF THIS SOFTWARE. + +ALTER TYPE connector_provider ADD VALUE IF NOT EXISTS 'QOVERY'; diff --git a/pkg/server/api/console/v1/connector_settings.go b/pkg/server/api/console/v1/connector_settings.go index 1da375490..6f23451bc 100644 --- a/pkg/server/api/console/v1/connector_settings.go +++ b/pkg/server/api/console/v1/connector_settings.go @@ -160,6 +160,12 @@ func apiKeyConnectorSettings(input types.CreateAPIKeyConnectorInput) (json.RawMe } return json.Marshal(&coredata.BetterStackConnectorSettings{TeamName: *input.BetterStackTeamName}) + case coredata.ConnectorProviderQovery: + if input.QoveryOrganizationID == nil || *input.QoveryOrganizationID == "" { + return nil, fmt.Errorf("cannot create qovery connector: qoveryOrganizationId is required") + } + + return json.Marshal(&coredata.QoveryConnectorSettings{OrganizationID: *input.QoveryOrganizationID}) } return nil, nil diff --git a/pkg/server/api/console/v1/graphql/connector.graphql b/pkg/server/api/console/v1/graphql/connector.graphql index d255299db..d961b4670 100644 --- a/pkg/server/api/console/v1/graphql/connector.graphql +++ b/pkg/server/api/console/v1/graphql/connector.graphql @@ -68,6 +68,7 @@ enum ConnectorProvider DATADOG @goEnum(value: "go.probo.inc/probo/pkg/coredata.ConnectorProviderDatadog") OKTA @goEnum(value: "go.probo.inc/probo/pkg/coredata.ConnectorProviderOkta") ZENDESK @goEnum(value: "go.probo.inc/probo/pkg/coredata.ConnectorProviderZendesk") + QOVERY @goEnum(value: "go.probo.inc/probo/pkg/coredata.ConnectorProviderQovery") } type ConnectorProviderInfo { @@ -146,6 +147,7 @@ input CreateAPIKeyConnectorInput { posthogInstanceUrl: String oktaDomain: String betterStackTeamName: String + qoveryOrganizationId: String } type CreateAPIKeyConnectorPayload {