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 {