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":
|
case "RENDER":
|
||||||
if (settingKey === "workspaceId") return "renderWorkspaceId";
|
if (settingKey === "workspaceId") return "renderWorkspaceId";
|
||||||
break;
|
break;
|
||||||
|
case "NEON":
|
||||||
|
if (settingKey === "organizationId") return "neonOrganizationId";
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
return null;
|
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 { Metabase } from "./Metabase";
|
||||||
import { Microsoft } from "./Microsoft";
|
import { Microsoft } from "./Microsoft";
|
||||||
import { Monday } from "./Monday";
|
import { Monday } from "./Monday";
|
||||||
|
import { Neon } from "./Neon";
|
||||||
import { Netlify } from "./Netlify";
|
import { Netlify } from "./Netlify";
|
||||||
import { Notion } from "./Notion";
|
import { Notion } from "./Notion";
|
||||||
import { Okta } from "./Okta";
|
import { Okta } from "./Okta";
|
||||||
@@ -83,6 +84,7 @@ const thirdParties: Record<string, FC<ComponentProps<"svg">>> = {
|
|||||||
MICROSOFT: Microsoft,
|
MICROSOFT: Microsoft,
|
||||||
MICROSOFT_365: Microsoft,
|
MICROSOFT_365: Microsoft,
|
||||||
MONDAY: Monday,
|
MONDAY: Monday,
|
||||||
|
NEON: Neon,
|
||||||
NETLIFY: Netlify,
|
NETLIFY: Netlify,
|
||||||
NOTION: Notion,
|
NOTION: Notion,
|
||||||
OKTA: Okta,
|
OKTA: Okta,
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export { Linear } from "./Linear";
|
|||||||
export { Metabase } from "./Metabase";
|
export { Metabase } from "./Metabase";
|
||||||
export { Microsoft } from "./Microsoft";
|
export { Microsoft } from "./Microsoft";
|
||||||
export { Monday } from "./Monday";
|
export { Monday } from "./Monday";
|
||||||
|
export { Neon } from "./Neon";
|
||||||
export { Netlify } from "./Netlify";
|
export { Netlify } from "./Netlify";
|
||||||
export { Notion } from "./Notion";
|
export { Notion } from "./Notion";
|
||||||
export { Okta } from "./Okta";
|
export { Okta } from "./Okta";
|
||||||
|
|||||||
@@ -408,6 +408,60 @@ func (r *renderNameResolver) ResolveInstanceName(ctx context.Context) (string, e
|
|||||||
return resp.Name, nil
|
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.
|
// hubspotNameResolver resolves the HubSpot account name.
|
||||||
type hubspotNameResolver struct {
|
type hubspotNameResolver struct {
|
||||||
httpClient *http.Client
|
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) {
|
func TestTailscaleNameResolver(t *testing.T) {
|
||||||
t.Parallel()
|
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(),
|
metabaseRegistration(),
|
||||||
microsoft365Registration(),
|
microsoft365Registration(),
|
||||||
mondayRegistration(),
|
mondayRegistration(),
|
||||||
|
neonRegistration(),
|
||||||
netlifyRegistration(),
|
netlifyRegistration(),
|
||||||
notionRegistration(),
|
notionRegistration(),
|
||||||
oktaRegistration(),
|
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"
|
ConnectorProviderZendesk ConnectorProvider = "ZENDESK"
|
||||||
ConnectorProviderQovery ConnectorProvider = "QOVERY"
|
ConnectorProviderQovery ConnectorProvider = "QOVERY"
|
||||||
ConnectorProviderRender ConnectorProvider = "RENDER"
|
ConnectorProviderRender ConnectorProvider = "RENDER"
|
||||||
|
ConnectorProviderNeon ConnectorProvider = "NEON"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -115,6 +116,7 @@ func ConnectorProviders() []ConnectorProvider {
|
|||||||
ConnectorProviderZendesk,
|
ConnectorProviderZendesk,
|
||||||
ConnectorProviderQovery,
|
ConnectorProviderQovery,
|
||||||
ConnectorProviderRender,
|
ConnectorProviderRender,
|
||||||
|
ConnectorProviderNeon,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,7 +163,8 @@ func (v ConnectorProvider) IsValid() bool {
|
|||||||
ConnectorProviderOkta,
|
ConnectorProviderOkta,
|
||||||
ConnectorProviderZendesk,
|
ConnectorProviderZendesk,
|
||||||
ConnectorProviderQovery,
|
ConnectorProviderQovery,
|
||||||
ConnectorProviderRender:
|
ConnectorProviderRender,
|
||||||
|
ConnectorProviderNeon:
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -152,6 +152,10 @@ type (
|
|||||||
RenderConnectorSettings struct {
|
RenderConnectorSettings struct {
|
||||||
OwnerID string `json:"owner_id"`
|
OwnerID string `json:"owner_id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
NeonConnectorSettings struct {
|
||||||
|
OrganizationID string `json:"organization_id"`
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
// GrantType returns the OAuth2 grant type recorded on the connector's
|
// 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})
|
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
|
return nil, nil
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ enum ConnectorProvider
|
|||||||
ZENDESK @goEnum(value: "go.probo.inc/probo/pkg/coredata.ConnectorProviderZendesk")
|
ZENDESK @goEnum(value: "go.probo.inc/probo/pkg/coredata.ConnectorProviderZendesk")
|
||||||
QOVERY @goEnum(value: "go.probo.inc/probo/pkg/coredata.ConnectorProviderQovery")
|
QOVERY @goEnum(value: "go.probo.inc/probo/pkg/coredata.ConnectorProviderQovery")
|
||||||
RENDER @goEnum(value: "go.probo.inc/probo/pkg/coredata.ConnectorProviderRender")
|
RENDER @goEnum(value: "go.probo.inc/probo/pkg/coredata.ConnectorProviderRender")
|
||||||
|
NEON @goEnum(value: "go.probo.inc/probo/pkg/coredata.ConnectorProviderNeon")
|
||||||
}
|
}
|
||||||
|
|
||||||
type ConnectorProviderInfo {
|
type ConnectorProviderInfo {
|
||||||
@@ -150,6 +151,7 @@ input CreateAPIKeyConnectorInput {
|
|||||||
betterStackTeamName: String
|
betterStackTeamName: String
|
||||||
qoveryOrganizationId: String
|
qoveryOrganizationId: String
|
||||||
renderWorkspaceId: String
|
renderWorkspaceId: String
|
||||||
|
neonOrganizationId: String
|
||||||
}
|
}
|
||||||
|
|
||||||
type CreateAPIKeyConnectorPayload {
|
type CreateAPIKeyConnectorPayload {
|
||||||
|
|||||||
Reference in New Issue
Block a user