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:
@@ -157,6 +157,9 @@ function mapAPIKeyExtraSettingToField(
|
||||
case "RENDER":
|
||||
if (settingKey === "workspaceId") return "renderWorkspaceId";
|
||||
break;
|
||||
case "NEON":
|
||||
if (settingKey === "organizationId") return "neonOrganizationId";
|
||||
break;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
16
packages/ui/src/Atoms/ThirdParties/Neon.tsx
Normal file
16
packages/ui/src/Atoms/ThirdParties/Neon.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
205
pkg/accessreview/drivers/neon.go
Normal file
205
pkg/accessreview/drivers/neon.go
Normal 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
|
||||
}
|
||||
128
pkg/accessreview/drivers/neon_test.go
Normal file
128
pkg/accessreview/drivers/neon_test.go
Normal 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))
|
||||
})
|
||||
}
|
||||
}
|
||||
79
pkg/accessreview/drivers/testdata/neon.yaml
vendored
Normal file
79
pkg/accessreview/drivers/testdata/neon.yaml
vendored
Normal 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
|
||||
@@ -44,6 +44,7 @@ func NewBuiltinRegistry() *Registry {
|
||||
metabaseRegistration(),
|
||||
microsoft365Registration(),
|
||||
mondayRegistration(),
|
||||
neonRegistration(),
|
||||
netlifyRegistration(),
|
||||
notionRegistration(),
|
||||
oktaRegistration(),
|
||||
|
||||
67
pkg/connector/provider/neon.go
Normal file
67
pkg/connector/provider/neon.go
Normal 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)
|
||||
},
|
||||
}
|
||||
}
|
||||
106
pkg/connector/provider/neon_test.go
Normal file
106
pkg/connector/provider/neon_test.go
Normal 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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
15
pkg/coredata/migrations/20260610T992854Z.sql
Normal file
15
pkg/coredata/migrations/20260610T992854Z.sql
Normal 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';
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user