Add Neon access review driver support

Register Neon as a connector provider and add a new access review
driver that fetches organization members from the Neon API with
cursor-based pagination.

Neon's OAuth is partner-gated, so the connector is API-key only
(Bearer, the default scheme). A personal or organization API key can
belong to several organizations; the operator supplies the ID of the
one to review. The members endpoint exposes per-user MFA state
(has_mfa) and deactivation, which map to the access entry MFA status
and active flag; the stable account UUID (user_id) is used as the
external ID over the membership ID.

Signed-off-by: Aurélien Sibiril <81782+aureliensibiril@users.noreply.github.com>
This commit is contained in:
Aurélien Sibiril
2026-06-10 00:49:57 +02:00
parent 7640376d32
commit ec858e58df
17 changed files with 763 additions and 1 deletions

View File

@@ -157,6 +157,9 @@ function mapAPIKeyExtraSettingToField(
case "RENDER":
if (settingKey === "workspaceId") return "renderWorkspaceId";
break;
case "NEON":
if (settingKey === "organizationId") return "neonOrganizationId";
break;
}
return null;
}

View File

@@ -0,0 +1,16 @@
import type { ComponentProps } from "react";
export function Neon(props: ComponentProps<"svg">) {
return (
<svg
viewBox="0 0 64 64"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M63 0.0177909V63.5526L38.4178 42.2501V63.5526H0V0L63 0.0177909ZM7.72251 55.8389H30.6953V25.3238L55.2779 47.0476V7.72922L7.72251 7.71559V55.8389Z"
fill="#37C38F"
/>
</svg>
);
}

View File

@@ -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<string, FC<ComponentProps<"svg">>> = {
MICROSOFT: Microsoft,
MICROSOFT_365: Microsoft,
MONDAY: Monday,
NEON: Neon,
NETLIFY: Netlify,
NOTION: Notion,
OKTA: Okta,

View File

@@ -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";

View File

@@ -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

View File

@@ -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()

View File

@@ -0,0 +1,205 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// 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
}

View File

@@ -0,0 +1,128 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// 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))
})
}
}

View File

@@ -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

View File

@@ -44,6 +44,7 @@ func NewBuiltinRegistry() *Registry {
metabaseRegistration(),
microsoft365Registration(),
mondayRegistration(),
neonRegistration(),
netlifyRegistration(),
notionRegistration(),
oktaRegistration(),

View File

@@ -0,0 +1,67 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// 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)
},
}
}

View File

@@ -0,0 +1,106 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// 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")
}

View File

@@ -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
}

View File

@@ -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

View File

@@ -0,0 +1,15 @@
-- Copyright (c) 2026 Probo Inc <hello@probo.com>.
--
-- 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';

View File

@@ -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

View File

@@ -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 {