From 7640376d323037a27185a600204939a950a721c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Sibiril?= <81782+aureliensibiril@users.noreply.github.com> Date: Tue, 9 Jun 2026 23:32:58 +0200 Subject: [PATCH] Add Render access review driver support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Register Render as an API-key connector provider and add an access review driver that fetches workspace members from the Render API (GET /v1/owners/{ownerId}/members). Render exposes no partner OAuth program, so the connector authenticates with a read-scoped API key (Authorization: Bearer) plus the customer's Workspace ID. The flat members endpoint reports an explicit account status and MFA flag, surfaced as the Active and MFAStatus fields; the stable "usr-" id becomes ExternalID. There is no picker -- the workspace is captured up front via ExtraSettings -- so SetOrganizationSettings is omitted. Signed-off-by: Aurélien Sibiril <81782+aureliensibiril@users.noreply.github.com> --- .../dialogs/AddAccessSourceDialog.tsx | 3 + packages/ui/src/Atoms/ThirdParties/Render.tsx | 16 ++ .../src/Atoms/ThirdParties/ThirdPartyLogo.tsx | 2 + packages/ui/src/Atoms/ThirdParties/index.ts | 1 + pkg/accessreview/drivers/name_resolver.go | 56 ++++++ .../drivers/name_resolver_test.go | 70 +++++++ pkg/accessreview/drivers/render.go | 175 ++++++++++++++++++ pkg/accessreview/drivers/render_test.go | 175 ++++++++++++++++++ pkg/accessreview/drivers/testdata/render.yaml | 42 +++++ pkg/connector/provider/builtin.go | 1 + pkg/connector/provider/render.go | 63 +++++++ pkg/connector/provider/render_test.go | 113 +++++++++++ pkg/coredata/connector_provider.go | 5 +- pkg/coredata/connector_settings.go | 8 + pkg/coredata/migrations/20260609T627194Z.sql | 15 ++ .../api/console/v1/connector_settings.go | 6 + .../api/console/v1/graphql/connector.graphql | 2 + 17 files changed, 752 insertions(+), 1 deletion(-) create mode 100644 packages/ui/src/Atoms/ThirdParties/Render.tsx create mode 100644 pkg/accessreview/drivers/render.go create mode 100644 pkg/accessreview/drivers/render_test.go create mode 100644 pkg/accessreview/drivers/testdata/render.yaml create mode 100644 pkg/connector/provider/render.go create mode 100644 pkg/connector/provider/render_test.go create mode 100644 pkg/coredata/migrations/20260609T627194Z.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 501cbe115..bc83bf0ee 100644 --- a/apps/console/src/pages/organizations/access-reviews/dialogs/AddAccessSourceDialog.tsx +++ b/apps/console/src/pages/organizations/access-reviews/dialogs/AddAccessSourceDialog.tsx @@ -154,6 +154,9 @@ function mapAPIKeyExtraSettingToField( case "QOVERY": if (settingKey === "organizationId") return "qoveryOrganizationId"; break; + case "RENDER": + if (settingKey === "workspaceId") return "renderWorkspaceId"; + break; } return null; } diff --git a/packages/ui/src/Atoms/ThirdParties/Render.tsx b/packages/ui/src/Atoms/ThirdParties/Render.tsx new file mode 100644 index 000000000..860685dc5 --- /dev/null +++ b/packages/ui/src/Atoms/ThirdParties/Render.tsx @@ -0,0 +1,16 @@ +import type { ComponentProps } from "react"; + +export function Render(props: ComponentProps<"svg">) { + return ( + + + + ); +} diff --git a/packages/ui/src/Atoms/ThirdParties/ThirdPartyLogo.tsx b/packages/ui/src/Atoms/ThirdParties/ThirdPartyLogo.tsx index 913a708b2..ea6e9e2a3 100644 --- a/packages/ui/src/Atoms/ThirdParties/ThirdPartyLogo.tsx +++ b/packages/ui/src/Atoms/ThirdParties/ThirdPartyLogo.tsx @@ -45,6 +45,7 @@ import { OpenAI } from "./OpenAI"; import { PagerDuty } from "./PagerDuty"; import { PostHog } from "./PostHog"; import { Qovery } from "./Qovery"; +import { Render } from "./Render"; import { Resend } from "./Resend"; import { SendGrid } from "./SendGrid"; import { Sentry } from "./Sentry"; @@ -91,6 +92,7 @@ const thirdParties: Record>> = { PAGERDUTY: PagerDuty, POSTHOG: PostHog, QOVERY: Qovery, + RENDER: Render, 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 c19dfdde1..74fc24c08 100644 --- a/packages/ui/src/Atoms/ThirdParties/index.ts +++ b/packages/ui/src/Atoms/ThirdParties/index.ts @@ -29,6 +29,7 @@ export { OpenAI } from "./OpenAI"; export { PagerDuty } from "./PagerDuty"; export { PostHog } from "./PostHog"; export { Qovery } from "./Qovery"; +export { Render } from "./Render"; 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 f5f08cd97..e1f5280ec 100644 --- a/pkg/accessreview/drivers/name_resolver.go +++ b/pkg/accessreview/drivers/name_resolver.go @@ -352,6 +352,62 @@ func (r *qoveryNameResolver) ResolveInstanceName(ctx context.Context) (string, e return resp.Name, nil } +// renderNameResolver resolves the Render workspace (owner) name from +// GET /v1/owners/{ownerId}, used to title the AccessSource "Render ". +type renderNameResolver struct { + httpClient *http.Client + ownerID string +} + +func NewRenderNameResolver(httpClient *http.Client, ownerID string) NameResolver { + return &renderNameResolver{ + httpClient: httpClient, + ownerID: ownerID, + } +} + +func (r *renderNameResolver) ResolveInstanceName(ctx context.Context) (string, error) { + if r.ownerID == "" { + return "", nil + } + + endpoint, err := url.JoinPath(renderAPIBaseURL, "owners", url.PathEscape(r.ownerID)) + if err != nil { + return "", fmt.Errorf("cannot build render owner URL: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return "", fmt.Errorf("cannot create render owner request: %w", err) + } + + req.Header.Set("Accept", "application/json") + + httpResp, err := r.httpClient.Do(req) + if err != nil { + return "", fmt.Errorf("cannot execute render owner request: %w", err) + } + + defer func() { _ = httpResp.Body.Close() }() + + // Best-effort: a non-2xx (revoked token, deleted workspace, 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 render owner 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 a52b222df..a77f893c2 100644 --- a/pkg/accessreview/drivers/name_resolver_test.go +++ b/pkg/accessreview/drivers/name_resolver_test.go @@ -255,6 +255,76 @@ func TestQoveryNameResolver(t *testing.T) { } } +func TestRenderNameResolver(t *testing.T) { + t.Parallel() + + t.Run("empty owner 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 owner id") + return nil, nil + })} + + got, err := NewRenderNameResolver(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":"tea-test","name":"Acme Workspace","email":"ops@example.com","type":"team"}`, + want: "Acme Workspace", + }, + { + name: "401 is terminal (no error, no name)", + status: http.StatusUnauthorized, + body: `{"message":"unauthorized"}`, + want: "", + }, + { + name: "404 is terminal (no error, no name)", + status: http.StatusNotFound, + body: `{"message":"not found"}`, + want: "", + }, + { + name: "500 is terminal (no error, no name)", + status: http.StatusInternalServerError, + body: `{"message":"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, "/v1/owners/tea-test", 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 := NewRenderNameResolver(client, "tea-test").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/render.go b/pkg/accessreview/drivers/render.go new file mode 100644 index 000000000..bb9fe0f29 --- /dev/null +++ b/pkg/accessreview/drivers/render.go @@ -0,0 +1,175 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package drivers + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "strings" + + "go.probo.inc/probo/pkg/coredata" +) + +const renderAPIBaseURL = "https://api.render.com/v1" + +type RenderDriver struct { + httpClient *http.Client + ownerID string +} + +var _ Driver = (*RenderDriver)(nil) + +// renderMember mirrors one element of the flat array returned by +// GET /v1/owners/{ownerId}/members. The endpoint takes no query parameters +// and is not paginated: it returns every workspace member (active and +// inactive) in a single response. +type renderMember struct { + UserID string `json:"userId"` + Name string `json:"name"` + Email string `json:"email"` + Status string `json:"status"` // "active" | "inactive" + Role string `json:"role"` // always uppercase + MFAEnabled bool `json:"mfaEnabled"` +} + +func NewRenderDriver(httpClient *http.Client, ownerID string) *RenderDriver { + return &RenderDriver{ + httpClient: httpClient, + ownerID: ownerID, + } +} + +func (d *RenderDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error) { + endpoint, err := url.JoinPath( + renderAPIBaseURL, + "owners", + url.PathEscape(d.ownerID), + "members", + ) + if err != nil { + return nil, fmt.Errorf("cannot build render members URL: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, fmt.Errorf("cannot create render 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 render members request: %w", err) + } + + defer func() { + _ = httpResp.Body.Close() + }() + + if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 { + return nil, fmt.Errorf("cannot fetch render members: unexpected status %d", httpResp.StatusCode) + } + + var members []renderMember + if err := json.NewDecoder(httpResp.Body).Decode(&members); err != nil { + return nil, fmt.Errorf("cannot decode render members response: %w", err) + } + + records := make([]AccountRecord, 0, len(members)) + for _, member := range members { + if member.Email == "" { + continue + } + + records = append(records, AccountRecord{ + Email: member.Email, + FullName: renderFullName(member), + Role: renderRole(member.Role), + Active: renderActive(member.Status), + IsAdmin: renderIsAdmin(member.Role), + MFAStatus: renderMFAStatus(member.MFAEnabled), + AuthMethod: coredata.AccessEntryAuthMethodUnknown, + AccountType: coredata.AccessEntryAccountTypeUser, + ExternalID: member.UserID, + }) + } + + return records, nil +} + +func renderFullName(member renderMember) string { + if member.Name != "" { + return member.Name + } + + return member.Email +} + +// renderRole maps Render's uppercase role enum to a human-readable label. +// Render documents ADMIN, DEVELOPER, WORKSPACE_CONTRIBUTOR, +// WORKSPACE_BILLING, and WORKSPACE_VIEWER; unknown future roles fall through +// to the raw value. +func renderRole(role string) string { + switch strings.ToUpper(role) { + case "ADMIN": + return "Admin" + case "DEVELOPER": + return "Developer" + case "WORKSPACE_CONTRIBUTOR": + return "Contributor" + case "WORKSPACE_BILLING": + return "Billing" + case "WORKSPACE_VIEWER": + return "Viewer" + default: + return role + } +} + +// renderIsAdmin reports whether a Render role grants workspace +// administration. Only ADMIN does (it also covers the workspace owner, who +// is reported with the ADMIN role). +func renderIsAdmin(role string) bool { + return strings.EqualFold(role, "ADMIN") +} + +func renderMFAStatus(enabled bool) coredata.MFAStatus { + if enabled { + return coredata.MFAStatusEnabled + } + + return coredata.MFAStatusDisabled +} + +// renderActive maps Render's documented status enum to the three-valued +// Active signal. Only the documented "active"/"inactive" values are an +// explicit signal; an empty or unrecognized status leaves Active nil +// (unknown) rather than fabricating a deactivated state, per the +// AccountRecord contract. +func renderActive(status string) *bool { + switch strings.ToLower(status) { + case "active": + active := true + return &active + case "inactive": + inactive := false + return &inactive + default: + return nil + } +} diff --git a/pkg/accessreview/drivers/render_test.go b/pkg/accessreview/drivers/render_test.go new file mode 100644 index 000000000..60a3b8ea7 --- /dev/null +++ b/pkg/accessreview/drivers/render_test.go @@ -0,0 +1,175 @@ +// 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" + "go.probo.inc/probo/pkg/coredata" +) + +func TestRenderDriverListAccounts(t *testing.T) { + t.Parallel() + + rec := newRecorder(t, "testdata/render", "RENDER_API_KEY") + client := newVCRClient(rec, bearerAuth(os.Getenv("RENDER_API_KEY"))) + + // RENDER_OWNER_ID supplies the live workspace id when recording; the + // default matches the anonymized cassette URL for replay. + ownerID := os.Getenv("RENDER_OWNER_ID") + if ownerID == "" { + ownerID = "tea-000000000000000000000" + } + + driver := NewRenderDriver(client, ownerID) + records, err := driver.ListAccounts(context.Background()) + require.NoError(t, err) + + // Four members in the cassette; the fourth has no email and is dropped. + require.Len(t, records, 3) + + // Admin: active with MFA enabled. ExternalID is Render's stable "usr-" id, + // never the email. + assert.Equal(t, "jane.doe@example.com", records[0].Email) + assert.Equal(t, "Jane Doe", records[0].FullName) + assert.Equal(t, "Admin", records[0].Role) + assert.True(t, records[0].IsAdmin) + assert.Equal(t, coredata.MFAStatusEnabled, records[0].MFAStatus) + assert.Equal(t, coredata.AccessEntryAccountTypeUser, records[0].AccountType) + assert.Equal(t, coredata.AccessEntryAuthMethodUnknown, records[0].AuthMethod) + assert.Equal(t, "usr-000000000000000000a1", records[0].ExternalID) + require.NotNil(t, records[0].Active) + assert.True(t, *records[0].Active) + + // Developer: active, MFA disabled, not an admin. + assert.Equal(t, "john.smith@example.com", records[1].Email) + assert.Equal(t, "John Smith", records[1].FullName) + assert.Equal(t, "Developer", records[1].Role) + assert.False(t, records[1].IsAdmin) + assert.Equal(t, coredata.MFAStatusDisabled, records[1].MFAStatus) + assert.Equal(t, "usr-000000000000000000b2", records[1].ExternalID) + require.NotNil(t, records[1].Active) + assert.True(t, *records[1].Active) + + // Workspace viewer: inactive → Active false; empty name falls back to the + // email so the row is never nameless. + assert.Equal(t, "sam.viewer@example.com", records[2].Email) + assert.Equal(t, "sam.viewer@example.com", records[2].FullName) + assert.Equal(t, "Viewer", records[2].Role) + assert.False(t, records[2].IsAdmin) + assert.Equal(t, coredata.MFAStatusDisabled, records[2].MFAStatus) + assert.Equal(t, "usr-000000000000000000c3", records[2].ExternalID) + require.NotNil(t, records[2].Active) + assert.False(t, *records[2].Active) +} + +func TestRenderDriverListAccountsError(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(`{"message":"unauthorized"}`)), + Header: make(http.Header), + }, nil + }, + ), + } + + driver := NewRenderDriver(client, "tea-000000000000000000000") + _, err := driver.ListAccounts(context.Background()) + require.Error(t, err) + assert.Contains(t, err.Error(), "unexpected status 401") + // The raw third-party body must never leak into the returned error. + assert.NotContains(t, err.Error(), "unauthorized") +} + +func TestRenderRole(t *testing.T) { + t.Parallel() + + cases := []struct { + in string + want string + isAdmin bool + }{ + {in: "ADMIN", want: "Admin", isAdmin: true}, + {in: "DEVELOPER", want: "Developer", isAdmin: false}, + {in: "WORKSPACE_CONTRIBUTOR", want: "Contributor", isAdmin: false}, + {in: "WORKSPACE_BILLING", want: "Billing", isAdmin: false}, + {in: "WORKSPACE_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, renderRole(c.in)) + assert.Equal(t, c.isAdmin, renderIsAdmin(c.in)) + }) + } +} + +func TestRenderMFAStatus(t *testing.T) { + t.Parallel() + + assert.Equal(t, coredata.MFAStatusEnabled, renderMFAStatus(true)) + assert.Equal(t, coredata.MFAStatusDisabled, renderMFAStatus(false)) +} + +func TestRenderActive(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + in string + wantSet bool + wantValue bool + }{ + {name: "active", in: "active", wantSet: true, wantValue: true}, + {name: "active uppercase", in: "ACTIVE", wantSet: true, wantValue: true}, + {name: "inactive", in: "inactive", wantSet: true, wantValue: false}, + {name: "inactive mixed case", in: "Inactive", wantSet: true, wantValue: false}, + // Undocumented / missing statuses must leave Active unset (unknown), + // not fabricate a deactivated signal. + {name: "empty", in: "", wantSet: false}, + {name: "pending", in: "pending", wantSet: false}, + {name: "suspended", in: "suspended", wantSet: false}, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + t.Parallel() + + got := renderActive(c.in) + if !c.wantSet { + assert.Nil(t, got) + return + } + + require.NotNil(t, got) + assert.Equal(t, c.wantValue, *got) + }) + } +} diff --git a/pkg/accessreview/drivers/testdata/render.yaml b/pkg/accessreview/drivers/testdata/render.yaml new file mode 100644 index 000000000..48e98b496 --- /dev/null +++ b/pkg/accessreview/drivers/testdata/render.yaml @@ -0,0 +1,42 @@ +--- +# Anonymized from a real GET /v1/owners/{ownerId}/members recording against a +# Render workspace (token stripped by the recorder, request-identifying +# headers removed). The live workspace had a single ADMIN member; real PII +# (owner ID, member userId, name, email) was replaced with synthetic values +# and three extra members were added to keep coverage: MFA-enabled admin, +# MFA-disabled developer, an inactive workspace viewer (empty name → email +# fallback), and an emailless member that is dropped. The member object shape +# (userId, name, email, status, role, mfaEnabled) and the flat, unpaginated +# array mirror the live response, including the uppercase role enum and the +# "usr-" / "tea-" id formats. +version: 2 +interactions: + - id: 0 + request: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + content_length: 0 + host: api.render.com + headers: + Accept: + - application/json + url: https://api.render.com/v1/owners/tea-000000000000000000000/members + method: GET + response: + proto: HTTP/2.0 + proto_major: 2 + proto_minor: 0 + content_length: -1 + uncompressed: true + body: '[{"email":"jane.doe@example.com","mfaEnabled":true,"name":"Jane Doe","role":"ADMIN","status":"active","userId":"usr-000000000000000000a1"},{"email":"john.smith@example.com","mfaEnabled":false,"name":"John Smith","role":"DEVELOPER","status":"active","userId":"usr-000000000000000000b2"},{"email":"sam.viewer@example.com","mfaEnabled":false,"name":"","role":"WORKSPACE_VIEWER","status":"inactive","userId":"usr-000000000000000000c3"},{"email":"","mfaEnabled":false,"name":"No Email","role":"WORKSPACE_BILLING","status":"active","userId":"usr-000000000000000000d4"}]' + headers: + Content-Type: + - application/json; charset=utf-8 + Strict-Transport-Security: + - max-age=315360000; includeSubDomains; preload + Vary: + - Origin + status: 200 OK + code: 200 + duration: 247.104708ms diff --git a/pkg/connector/provider/builtin.go b/pkg/connector/provider/builtin.go index 3fa439dde..8288c889e 100644 --- a/pkg/connector/provider/builtin.go +++ b/pkg/connector/provider/builtin.go @@ -52,6 +52,7 @@ func NewBuiltinRegistry() *Registry { posthogRegistration(), pagerdutyRegistration(), qoveryRegistration(), + renderRegistration(), resendRegistration(), sendgridRegistration(), sentryRegistration(), diff --git a/pkg/connector/provider/render.go b/pkg/connector/provider/render.go new file mode 100644 index 000000000..46ee132d9 --- /dev/null +++ b/pkg/connector/provider/render.go @@ -0,0 +1,63 @@ +// 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" +) + +// renderRegistration wires Render as an API-key access-review connector. +// Render exposes no partner OAuth program, so the customer supplies a +// read-scoped API key plus their Workspace ID (Render's owner ID). The key +// authenticates with the default Authorization: Bearer scheme, so no +// APIKeyAuthScheme override is set. There is no picker — the workspace is +// captured up front via ExtraSettings — so SetOrganizationSettings is omitted. +func renderRegistration() *Registration { + return &Registration{ + Provider: coredata.ConnectorProviderRender, + DisplayName: "Render", + SupportsAPIKey: true, + ExtraSettings: []ExtraSetting{ + {Key: "workspaceId", Label: "Workspace ID", Required: true}, + }, + NewDriver: func(_ context.Context, c *http.Client, conn *coredata.Connector, _ *log.Logger) (drivers.Driver, error) { + s, err := coredata.ConnectorSettings[coredata.RenderConnectorSettings](conn) + if err != nil { + return nil, fmt.Errorf("cannot read render connector settings: %w", err) + } + + if s.OwnerID == "" { + return nil, fmt.Errorf("cannot create render driver: owner_id is required") + } + + return drivers.NewRenderDriver(c, s.OwnerID), nil + }, + NewNameResolver: func(ctx context.Context, c *http.Client, conn *coredata.Connector, logger *log.Logger) drivers.NameResolver { + s, err := coredata.ConnectorSettings[coredata.RenderConnectorSettings](conn) + if err != nil { + logger.ErrorCtx(ctx, "cannot read render connector settings", log.Error(err)) + return nil + } + + return drivers.NewRenderNameResolver(c, s.OwnerID) + }, + } +} diff --git a/pkg/connector/provider/render_test.go b/pkg/connector/provider/render_test.go new file mode 100644 index 000000000..9f1cbdf08 --- /dev/null +++ b/pkg/connector/provider/render_test.go @@ -0,0 +1,113 @@ +// 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 TestRenderRegistrationMetadata(t *testing.T) { + t.Parallel() + + r := provider.NewBuiltinRegistry() + reg, ok := r.Get(coredata.ConnectorProviderRender) + require.True(t, ok, "render provider must be registered") + + assert.Equal(t, "Render", reg.DisplayName) + assert.True(t, reg.SupportsAPIKey) + // Render authenticates with the default Authorization: Bearer scheme. + assert.Empty(t, reg.APIKeyAuthScheme) + assert.Empty(t, reg.APIKeyHeader) + assert.False(t, reg.APIKeyBasicAuth) + // No OAuth and no picker. + assert.Empty(t, reg.AuthURL) + assert.Nil(t, reg.SetOrganizationSettings) + + require.Len(t, reg.ExtraSettings, 1) + assert.Equal(t, "workspaceId", reg.ExtraSettings[0].Key) + assert.Equal(t, "Workspace ID", reg.ExtraSettings[0].Label) + assert.True(t, reg.ExtraSettings[0].Required) +} + +func TestRenderNewDriver(t *testing.T) { + t.Parallel() + + r := provider.NewBuiltinRegistry() + reg, ok := r.Get(coredata.ConnectorProviderRender) + require.True(t, ok, "render provider must be registered") + require.NotNil(t, reg.NewDriver, "render NewDriver closure must be wired") + + t.Run("creates driver with valid owner id", func(t *testing.T) { + t.Parallel() + + raw, err := json.Marshal(&coredata.RenderConnectorSettings{ + OwnerID: "tea-csp8nlbgbbvc73a8nn9g", + }) + require.NoError(t, err) + + conn := &coredata.Connector{ + Provider: coredata.ConnectorProviderRender, + RawSettings: raw, + } + + drv, err := reg.NewDriver(context.Background(), httpclient.DefaultClient(httpclient.WithSSRFProtection()), conn, nil) + require.NoError(t, err) + assert.IsType(t, &drivers.RenderDriver{}, drv) + }) + + t.Run("errors when owner id is missing", func(t *testing.T) { + t.Parallel() + + conn := &coredata.Connector{ + Provider: coredata.ConnectorProviderRender, + RawSettings: []byte(`{}`), + } + + _, err := reg.NewDriver(context.Background(), httpclient.DefaultClient(httpclient.WithSSRFProtection()), conn, nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "owner_id is required") + }) +} + +func TestRenderNewNameResolver(t *testing.T) { + t.Parallel() + + r := provider.NewBuiltinRegistry() + reg, ok := r.Get(coredata.ConnectorProviderRender) + require.True(t, ok, "render provider must be registered") + require.NotNil(t, reg.NewNameResolver, "render NewNameResolver closure must be wired") + + raw, err := json.Marshal(&coredata.RenderConnectorSettings{ + OwnerID: "tea-csp8nlbgbbvc73a8nn9g", + }) + require.NoError(t, err) + + conn := &coredata.Connector{ + Provider: coredata.ConnectorProviderRender, + RawSettings: raw, + } + + resolver := reg.NewNameResolver(context.Background(), httpclient.DefaultClient(httpclient.WithSSRFProtection()), conn, nil) + require.NotNil(t, resolver, "render name resolver must be constructed for a valid connector") +} diff --git a/pkg/coredata/connector_provider.go b/pkg/coredata/connector_provider.go index b0c76c2ce..0fc148cfa 100644 --- a/pkg/coredata/connector_provider.go +++ b/pkg/coredata/connector_provider.go @@ -63,6 +63,7 @@ const ( ConnectorProviderOkta ConnectorProvider = "OKTA" ConnectorProviderZendesk ConnectorProvider = "ZENDESK" ConnectorProviderQovery ConnectorProvider = "QOVERY" + ConnectorProviderRender ConnectorProvider = "RENDER" ) var ( @@ -113,6 +114,7 @@ func ConnectorProviders() []ConnectorProvider { ConnectorProviderOkta, ConnectorProviderZendesk, ConnectorProviderQovery, + ConnectorProviderRender, } } @@ -158,7 +160,8 @@ func (v ConnectorProvider) IsValid() bool { ConnectorProviderDatadog, ConnectorProviderOkta, ConnectorProviderZendesk, - ConnectorProviderQovery: + ConnectorProviderQovery, + ConnectorProviderRender: return true } diff --git a/pkg/coredata/connector_settings.go b/pkg/coredata/connector_settings.go index 205e0d2f0..48e1cc669 100644 --- a/pkg/coredata/connector_settings.go +++ b/pkg/coredata/connector_settings.go @@ -144,6 +144,14 @@ type ( QoveryConnectorSettings struct { OrganizationID string `json:"organization_id"` } + + // RenderConnectorSettings stores the Render workspace identifier. The + // value is Render's owner ID (e.g. "tea-..." for a team workspace or + // "usr-..." for a personal one), surfaced to operators as "Workspace ID" + // and used as the {ownerId} path segment on /v1/owners/{ownerId}/... + RenderConnectorSettings struct { + OwnerID string `json:"owner_id"` + } ) // GrantType returns the OAuth2 grant type recorded on the connector's diff --git a/pkg/coredata/migrations/20260609T627194Z.sql b/pkg/coredata/migrations/20260609T627194Z.sql new file mode 100644 index 000000000..ee5f83e2f --- /dev/null +++ b/pkg/coredata/migrations/20260609T627194Z.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 'RENDER'; diff --git a/pkg/server/api/console/v1/connector_settings.go b/pkg/server/api/console/v1/connector_settings.go index 6f23451bc..f2adac64a 100644 --- a/pkg/server/api/console/v1/connector_settings.go +++ b/pkg/server/api/console/v1/connector_settings.go @@ -166,6 +166,12 @@ func apiKeyConnectorSettings(input types.CreateAPIKeyConnectorInput) (json.RawMe } return json.Marshal(&coredata.QoveryConnectorSettings{OrganizationID: *input.QoveryOrganizationID}) + case coredata.ConnectorProviderRender: + if input.RenderWorkspaceID == nil || *input.RenderWorkspaceID == "" { + return nil, fmt.Errorf("cannot create render connector: renderWorkspaceId is required") + } + + return json.Marshal(&coredata.RenderConnectorSettings{OwnerID: *input.RenderWorkspaceID}) } return nil, nil diff --git a/pkg/server/api/console/v1/graphql/connector.graphql b/pkg/server/api/console/v1/graphql/connector.graphql index d961b4670..8f8c5a1f3 100644 --- a/pkg/server/api/console/v1/graphql/connector.graphql +++ b/pkg/server/api/console/v1/graphql/connector.graphql @@ -69,6 +69,7 @@ enum ConnectorProvider 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") + RENDER @goEnum(value: "go.probo.inc/probo/pkg/coredata.ConnectorProviderRender") } type ConnectorProviderInfo { @@ -148,6 +149,7 @@ input CreateAPIKeyConnectorInput { oktaDomain: String betterStackTeamName: String qoveryOrganizationId: String + renderWorkspaceId: String } type CreateAPIKeyConnectorPayload {