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 bc83bf0ee..6cff5795d 100644 --- a/apps/console/src/pages/organizations/access-reviews/dialogs/AddAccessSourceDialog.tsx +++ b/apps/console/src/pages/organizations/access-reviews/dialogs/AddAccessSourceDialog.tsx @@ -157,6 +157,9 @@ function mapAPIKeyExtraSettingToField( case "RENDER": if (settingKey === "workspaceId") return "renderWorkspaceId"; break; + case "NEON": + if (settingKey === "organizationId") return "neonOrganizationId"; + break; } return null; } diff --git a/packages/ui/src/Atoms/ThirdParties/Neon.tsx b/packages/ui/src/Atoms/ThirdParties/Neon.tsx new file mode 100644 index 000000000..091147eea --- /dev/null +++ b/packages/ui/src/Atoms/ThirdParties/Neon.tsx @@ -0,0 +1,16 @@ +import type { ComponentProps } from "react"; + +export function Neon(props: ComponentProps<"svg">) { + return ( + + + + ); +} diff --git a/packages/ui/src/Atoms/ThirdParties/ThirdPartyLogo.tsx b/packages/ui/src/Atoms/ThirdParties/ThirdPartyLogo.tsx index ea6e9e2a3..910148feb 100644 --- a/packages/ui/src/Atoms/ThirdParties/ThirdPartyLogo.tsx +++ b/packages/ui/src/Atoms/ThirdParties/ThirdPartyLogo.tsx @@ -37,6 +37,7 @@ import { Linear } from "./Linear"; import { Metabase } from "./Metabase"; import { Microsoft } from "./Microsoft"; import { Monday } from "./Monday"; +import { Neon } from "./Neon"; import { Netlify } from "./Netlify"; import { Notion } from "./Notion"; import { Okta } from "./Okta"; @@ -83,6 +84,7 @@ const thirdParties: Record>> = { MICROSOFT: Microsoft, MICROSOFT_365: Microsoft, MONDAY: Monday, + NEON: Neon, NETLIFY: Netlify, NOTION: Notion, OKTA: Okta, diff --git a/packages/ui/src/Atoms/ThirdParties/index.ts b/packages/ui/src/Atoms/ThirdParties/index.ts index 74fc24c08..8a1128f57 100644 --- a/packages/ui/src/Atoms/ThirdParties/index.ts +++ b/packages/ui/src/Atoms/ThirdParties/index.ts @@ -21,6 +21,7 @@ export { Linear } from "./Linear"; export { Metabase } from "./Metabase"; export { Microsoft } from "./Microsoft"; export { Monday } from "./Monday"; +export { Neon } from "./Neon"; export { Netlify } from "./Netlify"; export { Notion } from "./Notion"; export { Okta } from "./Okta"; diff --git a/pkg/accessreview/drivers/name_resolver.go b/pkg/accessreview/drivers/name_resolver.go index e1f5280ec..cf6a7b204 100644 --- a/pkg/accessreview/drivers/name_resolver.go +++ b/pkg/accessreview/drivers/name_resolver.go @@ -408,6 +408,60 @@ func (r *renderNameResolver) ResolveInstanceName(ctx context.Context) (string, e return resp.Name, nil } +// neonNameResolver resolves the Neon organization name. +type neonNameResolver struct { + httpClient *http.Client + organizationID string +} + +func NewNeonNameResolver(httpClient *http.Client, organizationID string) NameResolver { + return &neonNameResolver{ + httpClient: httpClient, + organizationID: organizationID, + } +} + +func (r *neonNameResolver) ResolveInstanceName(ctx context.Context) (string, error) { + if r.organizationID == "" { + return "", nil + } + + endpoint, err := url.JoinPath(neonAPIBaseURL, "organizations", url.PathEscape(r.organizationID)) + if err != nil { + return "", fmt.Errorf("cannot build neon organization URL: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return "", fmt.Errorf("cannot create neon organization request: %w", err) + } + + req.Header.Set("Accept", "application/json") + + httpResp, err := r.httpClient.Do(req) + if err != nil { + return "", fmt.Errorf("cannot execute neon organization request: %w", err) + } + + defer func() { _ = httpResp.Body.Close() }() + + // Best-effort: a non-2xx (revoked key, deleted org, stale ID) must not + // make the source-name worker retry forever. Give up gracefully and keep + // the generic source name; a dead key 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 neon 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 a77f893c2..4111114c0 100644 --- a/pkg/accessreview/drivers/name_resolver_test.go +++ b/pkg/accessreview/drivers/name_resolver_test.go @@ -325,6 +325,76 @@ func TestRenderNameResolver(t *testing.T) { } } +func TestNeonNameResolver(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 := NewNeonNameResolver(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":"org-cool-breeze-12345678","name":"Acme Inc","handle":"acme-inc-org-cool-breeze-12345678","plan":"launch"}`, + 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, "/api/v2/organizations/org-cool-breeze-12345678", 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 := NewNeonNameResolver(client, "org-cool-breeze-12345678").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/neon.go b/pkg/accessreview/drivers/neon.go new file mode 100644 index 000000000..7939e0a9b --- /dev/null +++ b/pkg/accessreview/drivers/neon.go @@ -0,0 +1,205 @@ +// 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 ( + neonAPIBaseURL = "https://console.neon.tech/api/v2" + + // neonMembersPageLimit is the largest page size the Neon + // list-members endpoint documents (limit: 1..500). + neonMembersPageLimit = "500" +) + +type NeonDriver struct { + httpClient *http.Client + organizationID string +} + +var _ Driver = (*NeonDriver)(nil) + +type neonMembersResponse struct { + Members []neonOrgMember `json:"members"` + Pagination struct { + Next string `json:"next"` + } `json:"pagination"` +} + +type neonOrgMember struct { + Member struct { + ID string `json:"id"` + UserID string `json:"user_id"` + Role string `json:"role"` + JoinedAt string `json:"joined_at"` + } `json:"member"` + User struct { + Email string `json:"email"` + HasMFA *bool `json:"has_mfa"` + DeactivatedAt string `json:"deactivated_at"` + } `json:"user"` +} + +func NewNeonDriver(httpClient *http.Client, organizationID string) *NeonDriver { + return &NeonDriver{ + httpClient: httpClient, + organizationID: organizationID, + } +} + +func (d *NeonDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error) { + var ( + records []AccountRecord + cursor string + ) + + for range maxPaginationPages { + resp, err := d.queryMembers(ctx, cursor) + if err != nil { + return nil, err + } + + for _, m := range resp.Members { + if m.User.Email == "" { + continue + } + + records = append(records, AccountRecord{ + Email: m.User.Email, + // The members endpoint exposes no display name; + // fall back to the email. + FullName: m.User.Email, + Role: neonRole(m.Member.Role), + // deactivated_at is absent for active accounts. + Active: new(m.User.DeactivatedAt == ""), + IsAdmin: neonIsAdmin(m.Member.Role), + MFAStatus: neonMFAStatus(m.User.HasMFA), + AuthMethod: coredata.AccessEntryAuthMethodUnknown, + AccountType: coredata.AccessEntryAccountTypeUser, + ExternalID: neonExternalID(m), + CreatedAt: parseRFC3339Ptr(m.Member.JoinedAt), + }) + } + + if resp.Pagination.Next == "" { + return records, nil + } + + cursor = resp.Pagination.Next + } + + return nil, fmt.Errorf("cannot list all neon accounts: %w", ErrPaginationLimitReached) +} + +func (d *NeonDriver) queryMembers(ctx context.Context, cursor string) (*neonMembersResponse, error) { + endpoint, err := url.JoinPath( + neonAPIBaseURL, + "organizations", + url.PathEscape(d.organizationID), + "members", + ) + if err != nil { + return nil, fmt.Errorf("cannot build neon members URL: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, fmt.Errorf("cannot create neon members request: %w", err) + } + + req.Header.Set("Accept", "application/json") + + q := req.URL.Query() + q.Set("limit", neonMembersPageLimit) + + if cursor != "" { + q.Set("cursor", cursor) + } + + req.URL.RawQuery = q.Encode() + + httpResp, err := d.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("cannot execute neon members request: %w", err) + } + + defer func() { + _ = httpResp.Body.Close() + }() + + if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 { + return nil, fmt.Errorf("cannot fetch neon members: unexpected status %d", httpResp.StatusCode) + } + + var resp neonMembersResponse + if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil { + return nil, fmt.Errorf("cannot decode neon members response: %w", err) + } + + return &resp, nil +} + +// neonRole maps Neon's lowercase member roles to their display form, +// passing unknown values through unchanged. +func neonRole(role string) string { + switch strings.ToLower(role) { + case "admin": + return "Admin" + case "member": + return "Member" + case "editor": + return "Editor" + case "viewer": + return "Viewer" + case "collaborator": + return "Collaborator" + default: + return role + } +} + +func neonIsAdmin(role string) bool { + return strings.EqualFold(role, "admin") +} + +func neonMFAStatus(hasMFA *bool) coredata.MFAStatus { + switch { + case hasMFA == nil: + return coredata.MFAStatusUnknown + case *hasMFA: + return coredata.MFAStatusEnabled + default: + return coredata.MFAStatusDisabled + } +} + +// neonExternalID prefers the stable Neon account UUID over the +// membership ID. +func neonExternalID(m neonOrgMember) string { + if m.Member.UserID != "" { + return m.Member.UserID + } + + return m.Member.ID +} diff --git a/pkg/accessreview/drivers/neon_test.go b/pkg/accessreview/drivers/neon_test.go new file mode 100644 index 000000000..d99e004be --- /dev/null +++ b/pkg/accessreview/drivers/neon_test.go @@ -0,0 +1,128 @@ +// 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 TestNeonDriverListAccounts(t *testing.T) { + t.Parallel() + + rec := newRecorder(t, "testdata/neon", "NEON_API_KEY") + client := newVCRClient(rec, bearerAuth(os.Getenv("NEON_API_KEY"))) + + orgID := os.Getenv("NEON_ORG_ID") + if orgID == "" { + orgID = "org-cool-breeze-12345678" + } + + driver := NewNeonDriver(client, orgID) + records, err := driver.ListAccounts(context.Background()) + require.NoError(t, err) + + // Four members across two pages in the cassette; the fourth has no + // email and is dropped. + require.Len(t, records, 3) + + // Admin with MFA enabled and no deactivated_at. Neon exposes no + // display name on the members endpoint, so FullName is the email; + // ExternalID is the stable account UUID (member.user_id). + assert.Equal(t, "jane.doe@example.com", records[0].Email) + assert.Equal(t, "jane.doe@example.com", 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, "bbbbbbbb-1111-2222-3333-000000000001", records[0].ExternalID) + require.NotNil(t, records[0].Active) + assert.True(t, *records[0].Active) + require.NotNil(t, records[0].CreatedAt) + assert.Nil(t, records[0].LastLogin) + + // Deactivated member with MFA disabled. + assert.Equal(t, "john.smith@example.com", records[1].Email) + assert.Equal(t, "Member", records[1].Role) + assert.False(t, records[1].IsAdmin) + assert.Equal(t, coredata.MFAStatusDisabled, records[1].MFAStatus) + assert.Equal(t, "bbbbbbbb-1111-2222-3333-000000000002", records[1].ExternalID) + require.NotNil(t, records[1].Active) + assert.False(t, *records[1].Active) + + // Second page: editor with has_mfa omitted (Unknown) and an empty + // user_id falling back to the membership ID. + assert.Equal(t, "erin.lee@example.com", records[2].Email) + assert.Equal(t, "Editor", records[2].Role) + assert.False(t, records[2].IsAdmin) + assert.Equal(t, coredata.MFAStatusUnknown, records[2].MFAStatus) + assert.Equal(t, "aaaaaaaa-1111-2222-3333-000000000003", records[2].ExternalID) + require.NotNil(t, records[2].Active) + assert.True(t, *records[2].Active) +} + +func TestNeonDriverListAccountsError(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 := NewNeonDriver(client, "org-cool-breeze-12345678") + _, err := driver.ListAccounts(context.Background()) + require.Error(t, err) + assert.Contains(t, err.Error(), "unexpected status 401") +} + +func TestNeonRole(t *testing.T) { + t.Parallel() + + cases := []struct { + in string + want string + isAdmin bool + }{ + {in: "admin", want: "Admin", isAdmin: true}, + {in: "member", want: "Member", isAdmin: false}, + {in: "editor", want: "Editor", isAdmin: false}, + {in: "viewer", want: "Viewer", isAdmin: false}, + {in: "collaborator", want: "Collaborator", 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, neonRole(c.in)) + assert.Equal(t, c.isAdmin, neonIsAdmin(c.in)) + }) + } +} diff --git a/pkg/accessreview/drivers/testdata/neon.yaml b/pkg/accessreview/drivers/testdata/neon.yaml new file mode 100644 index 000000000..132bdb556 --- /dev/null +++ b/pkg/accessreview/drivers/testdata/neon.yaml @@ -0,0 +1,79 @@ +--- +# Anonymized from a real GET /api/v2/organizations/{org_id}/members recording +# against a Neon organization (Authorization stripped by the recorder). Real +# PII (org ID, membership/account UUIDs, email) replaced with synthetic +# values; identifying response headers (Cf-Ray, request IDs, dates) dropped. +# Three extra members and a second page were added to keep coverage: a +# deactivated member with MFA disabled, an editor with has_mfa omitted and an +# empty user_id (membership-ID fallback), an emailless member that is +# dropped, and cursor pagination via pagination.next. The member object shape +# ({member:{id,user_id,org_id,role,joined_at},user:{email,has_mfa, +# deactivated_at}} + pagination) mirrors the live response. +version: 2 +interactions: + - id: 0 + request: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + content_length: 0 + host: console.neon.tech + form: + limit: + - "500" + headers: + Accept: + - application/json + url: https://console.neon.tech/api/v2/organizations/org-cool-breeze-12345678/members?limit=500 + method: GET + response: + proto: HTTP/2.0 + proto_major: 2 + proto_minor: 0 + content_length: -1 + uncompressed: true + body: '{"members":[{"member":{"id":"aaaaaaaa-1111-2222-3333-000000000001","user_id":"bbbbbbbb-1111-2222-3333-000000000001","org_id":"org-cool-breeze-12345678","role":"admin","joined_at":"2025-11-12T13:26:01Z"},"user":{"email":"jane.doe@example.com","has_mfa":true}},{"member":{"id":"aaaaaaaa-1111-2222-3333-000000000002","user_id":"bbbbbbbb-1111-2222-3333-000000000002","org_id":"org-cool-breeze-12345678","role":"member","joined_at":"2025-12-01T09:00:00Z"},"user":{"email":"john.smith@example.com","has_mfa":false,"deactivated_at":"2026-03-15T10:30:00Z"}}],"pagination":{"next":"am9obi5zbWl0aA","sort_by":"joined_at","sort_order":"desc"}}' + headers: + Content-Type: + - application/json; charset=utf-8 + Strict-Transport-Security: + - max-age=63072000; includeSubDomains; preload + Vary: + - Origin + status: 200 OK + code: 200 + duration: 466.274666ms + - id: 1 + request: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + content_length: 0 + host: console.neon.tech + form: + cursor: + - am9obi5zbWl0aA + limit: + - "500" + headers: + Accept: + - application/json + url: https://console.neon.tech/api/v2/organizations/org-cool-breeze-12345678/members?cursor=am9obi5zbWl0aA&limit=500 + method: GET + response: + proto: HTTP/2.0 + proto_major: 2 + proto_minor: 0 + content_length: -1 + uncompressed: true + body: '{"members":[{"member":{"id":"aaaaaaaa-1111-2222-3333-000000000003","user_id":"","org_id":"org-cool-breeze-12345678","role":"editor","joined_at":"2026-01-20T14:45:00Z"},"user":{"email":"erin.lee@example.com"}},{"member":{"id":"aaaaaaaa-1111-2222-3333-000000000004","user_id":"bbbbbbbb-1111-2222-3333-000000000004","org_id":"org-cool-breeze-12345678","role":"viewer","joined_at":"2026-02-02T08:00:00Z"},"user":{"email":"","has_mfa":false}}],"pagination":{"sort_by":"joined_at","sort_order":"desc"}}' + headers: + Content-Type: + - application/json; charset=utf-8 + Strict-Transport-Security: + - max-age=63072000; includeSubDomains; preload + Vary: + - Origin + status: 200 OK + code: 200 + duration: 412.118042ms diff --git a/pkg/connector/provider/builtin.go b/pkg/connector/provider/builtin.go index 8288c889e..b2bf5b8bf 100644 --- a/pkg/connector/provider/builtin.go +++ b/pkg/connector/provider/builtin.go @@ -44,6 +44,7 @@ func NewBuiltinRegistry() *Registry { metabaseRegistration(), microsoft365Registration(), mondayRegistration(), + neonRegistration(), netlifyRegistration(), notionRegistration(), oktaRegistration(), diff --git a/pkg/connector/provider/neon.go b/pkg/connector/provider/neon.go new file mode 100644 index 000000000..a3c84c3ae --- /dev/null +++ b/pkg/connector/provider/neon.go @@ -0,0 +1,67 @@ +// 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 neonRegistration() *Registration { + return &Registration{ + Provider: coredata.ConnectorProviderNeon, + DisplayName: "Neon", + // Neon's API authenticates with an API key (napi_...) presented + // as Authorization: Bearer, the default APIKeyConnection scheme. + // Neon's OAuth is partner-gated (manual application), so the + // connector is API-key only. A personal or organization API key + // can belong to several organizations; the operator supplies the + // org ID (org-...) of the one to review. + // + // ProbeURL is intentionally empty: the connection probe runs only + // for OAuth2 connections, so it would be dead config for an + // API-key provider; a dead key surfaces on the first ListAccounts. + SupportsAPIKey: true, + 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.NeonConnectorSettings](conn) + if err != nil { + return nil, fmt.Errorf("cannot read neon connector settings: %w", err) + } + + if s.OrganizationID == "" { + return nil, fmt.Errorf("cannot create neon driver: organization_id is required") + } + + return drivers.NewNeonDriver(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.NeonConnectorSettings](conn) + if err != nil { + logger.ErrorCtx(ctx, "cannot read neon connector settings", log.Error(err)) + return nil + } + + return drivers.NewNeonNameResolver(c, s.OrganizationID) + }, + } +} diff --git a/pkg/connector/provider/neon_test.go b/pkg/connector/provider/neon_test.go new file mode 100644 index 000000000..3c19523fd --- /dev/null +++ b/pkg/connector/provider/neon_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 TestNeonRegistrationMetadata(t *testing.T) { + t.Parallel() + + r := provider.NewBuiltinRegistry() + reg, ok := r.Get(coredata.ConnectorProviderNeon) + require.True(t, ok, "neon provider must be registered") + + assert.Equal(t, "Neon", reg.DisplayName) + assert.True(t, reg.SupportsAPIKey) + assert.Empty(t, reg.APIKeyAuthScheme, "neon API keys use the default Bearer scheme") + 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 TestNeonNewDriver(t *testing.T) { + t.Parallel() + + r := provider.NewBuiltinRegistry() + reg, ok := r.Get(coredata.ConnectorProviderNeon) + require.True(t, ok, "neon provider must be registered") + require.NotNil(t, reg.NewDriver, "neon NewDriver closure must be wired") + + t.Run("creates driver with valid organization_id", func(t *testing.T) { + t.Parallel() + + raw, err := json.Marshal(&coredata.NeonConnectorSettings{ + OrganizationID: "org-cool-breeze-12345678", + }) + require.NoError(t, err) + + conn := &coredata.Connector{ + Provider: coredata.ConnectorProviderNeon, + RawSettings: raw, + } + + drv, err := reg.NewDriver(context.Background(), httpclient.DefaultClient(httpclient.WithSSRFProtection()), conn, nil) + require.NoError(t, err) + assert.IsType(t, &drivers.NeonDriver{}, drv) + }) + + t.Run("errors when organization_id is missing", func(t *testing.T) { + t.Parallel() + + conn := &coredata.Connector{ + Provider: coredata.ConnectorProviderNeon, + 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 TestNeonNewNameResolver(t *testing.T) { + t.Parallel() + + r := provider.NewBuiltinRegistry() + reg, ok := r.Get(coredata.ConnectorProviderNeon) + require.True(t, ok, "neon provider must be registered") + require.NotNil(t, reg.NewNameResolver, "neon NewNameResolver closure must be wired") + + raw, err := json.Marshal(&coredata.NeonConnectorSettings{ + OrganizationID: "org-cool-breeze-12345678", + }) + require.NoError(t, err) + + conn := &coredata.Connector{ + Provider: coredata.ConnectorProviderNeon, + RawSettings: raw, + } + + resolver := reg.NewNameResolver(context.Background(), httpclient.DefaultClient(httpclient.WithSSRFProtection()), conn, nil) + require.NotNil(t, resolver, "neon name resolver must be constructed for a valid connector") +} diff --git a/pkg/coredata/connector_provider.go b/pkg/coredata/connector_provider.go index 0fc148cfa..5bfafc27c 100644 --- a/pkg/coredata/connector_provider.go +++ b/pkg/coredata/connector_provider.go @@ -64,6 +64,7 @@ const ( ConnectorProviderZendesk ConnectorProvider = "ZENDESK" ConnectorProviderQovery ConnectorProvider = "QOVERY" ConnectorProviderRender ConnectorProvider = "RENDER" + ConnectorProviderNeon ConnectorProvider = "NEON" ) var ( @@ -115,6 +116,7 @@ func ConnectorProviders() []ConnectorProvider { ConnectorProviderZendesk, ConnectorProviderQovery, ConnectorProviderRender, + ConnectorProviderNeon, } } @@ -161,7 +163,8 @@ func (v ConnectorProvider) IsValid() bool { ConnectorProviderOkta, ConnectorProviderZendesk, ConnectorProviderQovery, - ConnectorProviderRender: + ConnectorProviderRender, + ConnectorProviderNeon: return true } diff --git a/pkg/coredata/connector_settings.go b/pkg/coredata/connector_settings.go index 48e1cc669..b6fe32dc0 100644 --- a/pkg/coredata/connector_settings.go +++ b/pkg/coredata/connector_settings.go @@ -152,6 +152,10 @@ type ( RenderConnectorSettings struct { OwnerID string `json:"owner_id"` } + + NeonConnectorSettings struct { + OrganizationID string `json:"organization_id"` + } ) // GrantType returns the OAuth2 grant type recorded on the connector's diff --git a/pkg/coredata/migrations/20260610T992854Z.sql b/pkg/coredata/migrations/20260610T992854Z.sql new file mode 100644 index 000000000..524ca1311 --- /dev/null +++ b/pkg/coredata/migrations/20260610T992854Z.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 'NEON'; diff --git a/pkg/server/api/console/v1/connector_settings.go b/pkg/server/api/console/v1/connector_settings.go index f2adac64a..c3e37b886 100644 --- a/pkg/server/api/console/v1/connector_settings.go +++ b/pkg/server/api/console/v1/connector_settings.go @@ -172,6 +172,12 @@ func apiKeyConnectorSettings(input types.CreateAPIKeyConnectorInput) (json.RawMe } return json.Marshal(&coredata.RenderConnectorSettings{OwnerID: *input.RenderWorkspaceID}) + case coredata.ConnectorProviderNeon: + if input.NeonOrganizationID == nil || *input.NeonOrganizationID == "" { + return nil, fmt.Errorf("cannot create neon connector: neonOrganizationId is required") + } + + return json.Marshal(&coredata.NeonConnectorSettings{OrganizationID: *input.NeonOrganizationID}) } return nil, nil diff --git a/pkg/server/api/console/v1/graphql/connector.graphql b/pkg/server/api/console/v1/graphql/connector.graphql index 8f8c5a1f3..9b007c298 100644 --- a/pkg/server/api/console/v1/graphql/connector.graphql +++ b/pkg/server/api/console/v1/graphql/connector.graphql @@ -70,6 +70,7 @@ enum ConnectorProvider 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") + NEON @goEnum(value: "go.probo.inc/probo/pkg/coredata.ConnectorProviderNeon") } type ConnectorProviderInfo { @@ -150,6 +151,7 @@ input CreateAPIKeyConnectorInput { betterStackTeamName: String qoveryOrganizationId: String renderWorkspaceId: String + neonOrganizationId: String } type CreateAPIKeyConnectorPayload {