Add four API-key access-review connectors
Add Pylon, OpenRouter, incident.io and Brevo as access-review connectors. All are API-key, single-tenant providers (Pattern 3): the key identifies one tenant, so there is no OAuth flow, picker UI, or bootstrap/helm configuration. - Pylon: Bearer token, GET /users; resolves each user's opaque role_id to a role name via GET /user-roles, with cursor pagination. - OpenRouter: Bearer management key, GET /api/v1/organization/members. The endpoint requires an organization account -- a personal key authenticates but returns 404 -- so the connection probe rejects 404 on top of 401/403 (doProbeRequest gained an opt-in extra-reject set) to surface a non-org key at connect time instead of mid-campaign. - incident.io: Bearer token, GET /v2/users. Its OAuth is outbound-only, so the API key is the inbound path; live base_role/custom_roles take precedence over the deprecated role enum. - Brevo: API key in the api-key header (Registration.APIKeyHeader), GET /v3/organization/invited/users. A live recording corrected the documented schema: is_owner is a JSON boolean (not a string) and an id field is present, so it is used as the stable ExternalID. The OpenRouter and Brevo cassettes are anonymized live recordings; Pylon and incident.io use hand-authored fixtures (no self-serve test tenant). The shared three-valued active-status mapping is consolidated into activeFromStatus in driver.go. Each adds the enum value, migration, GraphQL binding, provider Registration, a driver with a cassette-driven test, and a brand logo. Signed-off-by: Aurélien Sibiril <81782+aureliensibiril@users.noreply.github.com>
This commit is contained in:
12
packages/ui/src/Atoms/ThirdParties/Brevo.tsx
Normal file
12
packages/ui/src/Atoms/ThirdParties/Brevo.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import type { ComponentProps } from "react";
|
||||
|
||||
export function Brevo(props: ComponentProps<"svg">) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" {...props}>
|
||||
<path
|
||||
fill="#0B996E"
|
||||
d="M12 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0zM7.2 4.8h5.747c2.34 0 3.895 1.406 3.895 3.516 0 1.022-.348 1.862-1.09 2.588C17.189 11.812 18 13.22 18 14.785c0 2.86-2.64 5.016-6.164 5.016H7.199v-15zm2.085 1.952v5.537h.07c.233-.432.858-.796 2.249-1.226 2.039-.659 3.037-1.52 3.037-2.655 0-.998-.766-1.656-1.924-1.656H9.285zm4.87 5.266c-.766.385-1.67.748-2.76 1.11-1.229.387-2.11 1.386-2.11 2.407v2.315h2.365c2.387 0 4.149-1.34 4.149-3.155 0-1.067-.625-2.087-1.645-2.677z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
17
packages/ui/src/Atoms/ThirdParties/IncidentIO.tsx
Normal file
17
packages/ui/src/Atoms/ThirdParties/IncidentIO.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { ComponentProps } from "react";
|
||||
|
||||
export function IncidentIO(props: ComponentProps<"svg">) {
|
||||
return (
|
||||
<svg viewBox="0 0 48 48" xmlns="http://www.w3.org/2000/svg" {...props}>
|
||||
<rect
|
||||
x="42"
|
||||
y="19.5"
|
||||
width="9"
|
||||
height="36"
|
||||
transform="rotate(90 42 19.5)"
|
||||
fill="#F25533"
|
||||
/>
|
||||
<rect x="19.5" y="6" width="9" height="36" fill="#F25533" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
12
packages/ui/src/Atoms/ThirdParties/OpenRouter.tsx
Normal file
12
packages/ui/src/Atoms/ThirdParties/OpenRouter.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import type { ComponentProps } from "react";
|
||||
|
||||
export function OpenRouter(props: ComponentProps<"svg">) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" {...props}>
|
||||
<path
|
||||
fill="#94A3B8"
|
||||
d="M16.778 1.844v1.919q-.569-.026-1.138-.032-.708-.008-1.415.037c-1.93.126-4.023.728-6.149 2.237-2.911 2.066-2.731 1.95-4.14 2.75-.396.223-1.342.574-2.185.798-.841.225-1.753.333-1.751.333v4.229s.768.108 1.61.333c.842.224 1.789.575 2.185.799 1.41.798 1.228.683 4.14 2.75 2.126 1.509 4.22 2.11 6.148 2.236.88.058 1.716.041 2.555.005v1.918l7.222-4.168-7.222-4.17v2.176c-.86.038-1.611.065-2.278.021-1.364-.09-2.417-.357-3.979-1.465-2.244-1.593-2.866-2.027-3.68-2.508.889-.518 1.449-.906 3.822-2.59 1.56-1.109 2.614-1.377 3.978-1.466.667-.044 1.418-.017 2.278.02v2.176L24 6.014Z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
23
packages/ui/src/Atoms/ThirdParties/Pylon.tsx
Normal file
23
packages/ui/src/Atoms/ThirdParties/Pylon.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import type { ComponentProps } from "react";
|
||||
|
||||
export function Pylon(props: ComponentProps<"svg">) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 614 614"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<circle cx="306" cy="306" r="277" fill="none" stroke="#5B0EFF" strokeWidth="60" />
|
||||
<clipPath id="pylon-logo-clip">
|
||||
<circle cx="306" cy="306" r="247" />
|
||||
</clipPath>
|
||||
<g clipPath="url(#pylon-logo-clip)" fill="#5B0EFF">
|
||||
<rect x="279" y="0" width="56" height="614" />
|
||||
<rect x="0" y="155" width="335" height="60" />
|
||||
<rect x="0" y="277" width="335" height="60" />
|
||||
<rect x="0" y="399" width="335" height="60" />
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import { Apollo } from "./Apollo";
|
||||
import { Asana } from "./Asana";
|
||||
import { BetterStack } from "./BetterStack";
|
||||
import { Bitbucket } from "./Bitbucket";
|
||||
import { Brevo } from "./Brevo";
|
||||
import { Brex } from "./Brex";
|
||||
import { Clerk } from "./Clerk";
|
||||
import { ClickHouse } from "./ClickHouse";
|
||||
@@ -35,6 +36,7 @@ import { Google } from "./Google";
|
||||
import { Grafana } from "./Grafana";
|
||||
import { Heroku } from "./Heroku";
|
||||
import { HubSpot } from "./HubSpot";
|
||||
import { IncidentIO } from "./IncidentIO";
|
||||
import { Intercom } from "./Intercom";
|
||||
import { Langfuse } from "./Langfuse";
|
||||
import { Linear } from "./Linear";
|
||||
@@ -48,8 +50,10 @@ import { Notion } from "./Notion";
|
||||
import { Okta } from "./Okta";
|
||||
import { OnePassword } from "./OnePassword";
|
||||
import { OpenAI } from "./OpenAI";
|
||||
import { OpenRouter } from "./OpenRouter";
|
||||
import { PagerDuty } from "./PagerDuty";
|
||||
import { PostHog } from "./PostHog";
|
||||
import { Pylon } from "./Pylon";
|
||||
import { Qovery } from "./Qovery";
|
||||
import { Render } from "./Render";
|
||||
import { Resend } from "./Resend";
|
||||
@@ -69,6 +73,7 @@ const thirdParties: Record<string, FC<ComponentProps<"svg">>> = {
|
||||
ASANA: Asana,
|
||||
BETTER_STACK: BetterStack,
|
||||
BITBUCKET: Bitbucket,
|
||||
BREVO: Brevo,
|
||||
BREX: Brex,
|
||||
CLERK: Clerk,
|
||||
CLICKHOUSE: ClickHouse,
|
||||
@@ -86,6 +91,7 @@ const thirdParties: Record<string, FC<ComponentProps<"svg">>> = {
|
||||
GRAFANA: Grafana,
|
||||
HEROKU: Heroku,
|
||||
HUBSPOT: HubSpot,
|
||||
INCIDENT_IO: IncidentIO,
|
||||
INTERCOM: Intercom,
|
||||
LANGFUSE: Langfuse,
|
||||
LINEAR: Linear,
|
||||
@@ -101,8 +107,10 @@ const thirdParties: Record<string, FC<ComponentProps<"svg">>> = {
|
||||
ONE_PASSWORD: OnePassword,
|
||||
ONEPASSWORD: OnePassword,
|
||||
OPENAI: OpenAI,
|
||||
OPENROUTER: OpenRouter,
|
||||
PAGERDUTY: PagerDuty,
|
||||
POSTHOG: PostHog,
|
||||
PYLON: Pylon,
|
||||
QOVERY: Qovery,
|
||||
RENDER: Render,
|
||||
RESEND: Resend,
|
||||
|
||||
@@ -3,6 +3,7 @@ export { Apollo } from "./Apollo";
|
||||
export { Asana } from "./Asana";
|
||||
export { BetterStack } from "./BetterStack";
|
||||
export { Bitbucket } from "./Bitbucket";
|
||||
export { Brevo } from "./Brevo";
|
||||
export { Brex } from "./Brex";
|
||||
export { Clerk } from "./Clerk";
|
||||
export { ClickHouse } from "./ClickHouse";
|
||||
@@ -19,6 +20,7 @@ export { Google } from "./Google";
|
||||
export { Grafana } from "./Grafana";
|
||||
export { Heroku } from "./Heroku";
|
||||
export { HubSpot } from "./HubSpot";
|
||||
export { IncidentIO } from "./IncidentIO";
|
||||
export { Intercom } from "./Intercom";
|
||||
export { Langfuse } from "./Langfuse";
|
||||
export { Linear } from "./Linear";
|
||||
@@ -32,8 +34,10 @@ export { Notion } from "./Notion";
|
||||
export { Okta } from "./Okta";
|
||||
export { OnePassword } from "./OnePassword";
|
||||
export { OpenAI } from "./OpenAI";
|
||||
export { OpenRouter } from "./OpenRouter";
|
||||
export { PagerDuty } from "./PagerDuty";
|
||||
export { PostHog } from "./PostHog";
|
||||
export { Pylon } from "./Pylon";
|
||||
export { Qovery } from "./Qovery";
|
||||
export { Render } from "./Render";
|
||||
export { Resend } from "./Resend";
|
||||
|
||||
181
pkg/accessreview/drivers/brevo.go
Normal file
181
pkg/accessreview/drivers/brevo.go
Normal file
@@ -0,0 +1,181 @@
|
||||
// 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"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"go.probo.inc/probo/pkg/coredata"
|
||||
)
|
||||
|
||||
const brevoInvitedUsersEndpoint = "https://api.brevo.com/v3/organization/invited/users"
|
||||
|
||||
// BrevoDriver lists the invited users (organization seats) of a single Brevo
|
||||
// account. The API key (sent in the api-key header by the connection
|
||||
// transport) is bound to one account, so GET /v3/organization/invited/users
|
||||
// returns every invited user of that account with no tenant selector and no
|
||||
// pagination.
|
||||
type BrevoDriver struct {
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
var _ Driver = (*BrevoDriver)(nil)
|
||||
|
||||
type brevoInvitedUser struct {
|
||||
// ID is Brevo's stable user identifier (a Mongo-style ObjectID). The
|
||||
// documented schema omits it, but the live API returns it, so it is
|
||||
// preferred over the email as the ExternalID.
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
// IsOwner flags the account owner. The live API returns a JSON boolean
|
||||
// while older docs/SDK show a string ("true"/"false"); decoded as
|
||||
// RawMessage and read via brevoIsOwner to tolerate both shapes.
|
||||
IsOwner json.RawMessage `json:"is_owner"`
|
||||
// Status is the invitation state: "active" or "pending".
|
||||
Status string `json:"status"`
|
||||
// FeatureAccess maps a feature area (marketing / crm / conversations /
|
||||
// transactional / phone / …) to the user's access level on it. Values are
|
||||
// strings (e.g. "owner", "full", "none"); decoded as RawMessage so a
|
||||
// non-string shape degrades gracefully instead of failing the whole
|
||||
// decode.
|
||||
FeatureAccess map[string]json.RawMessage `json:"feature_access"`
|
||||
}
|
||||
|
||||
type brevoInvitedUsersResponse struct {
|
||||
Users []brevoInvitedUser `json:"users"`
|
||||
}
|
||||
|
||||
func NewBrevoDriver(httpClient *http.Client) *BrevoDriver {
|
||||
return &BrevoDriver{httpClient: httpClient}
|
||||
}
|
||||
|
||||
func (d *BrevoDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, brevoInvitedUsersEndpoint, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot create brevo invited users request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
httpResp, err := d.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot execute brevo invited users request: %w", err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
_ = httpResp.Body.Close()
|
||||
}()
|
||||
|
||||
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("cannot fetch brevo invited users: unexpected status %d", httpResp.StatusCode)
|
||||
}
|
||||
|
||||
var resp brevoInvitedUsersResponse
|
||||
if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil {
|
||||
return nil, fmt.Errorf("cannot decode brevo invited users response: %w", err)
|
||||
}
|
||||
|
||||
records := make([]AccountRecord, 0, len(resp.Users))
|
||||
|
||||
for _, u := range resp.Users {
|
||||
email := strings.TrimSpace(u.Email)
|
||||
if email == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
records = append(records, AccountRecord{
|
||||
Email: email,
|
||||
// Brevo's invited-users API exposes no display name.
|
||||
FullName: email,
|
||||
Roles: brevoRoles(u.FeatureAccess),
|
||||
Active: activeFromStatus(u.Status),
|
||||
IsAdmin: brevoIsOwner(u.IsOwner),
|
||||
MFAStatus: coredata.MFAStatusUnknown,
|
||||
AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown,
|
||||
AccountType: coredata.AccessReviewEntryAccountTypeUser,
|
||||
ExternalID: brevoExternalID(u, email),
|
||||
})
|
||||
}
|
||||
|
||||
return records, nil
|
||||
}
|
||||
|
||||
// brevoExternalID prefers Brevo's stable user id, falling back to the email
|
||||
// (the only other durable identifier) when an account has none.
|
||||
func brevoExternalID(u brevoInvitedUser, email string) string {
|
||||
if id := strings.TrimSpace(u.ID); id != "" {
|
||||
return id
|
||||
}
|
||||
|
||||
return email
|
||||
}
|
||||
|
||||
// brevoIsOwner reads the account-owner flag, tolerating both the JSON boolean
|
||||
// the live API returns and the string ("true"/"false") shown in older
|
||||
// docs/SDK.
|
||||
func brevoIsOwner(raw json.RawMessage) bool {
|
||||
if len(raw) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
var b bool
|
||||
if err := json.Unmarshal(raw, &b); err == nil {
|
||||
return b
|
||||
}
|
||||
|
||||
var s string
|
||||
if err := json.Unmarshal(raw, &s); err == nil {
|
||||
return strings.EqualFold(strings.TrimSpace(s), "true")
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// brevoRoles summarises an invited user's per-feature access levels
|
||||
// (marketing / crm / conversations) into a de-duplicated, sorted set of role
|
||||
// labels. Each feature_access value is normally a string such as "owner";
|
||||
// the "none" level and any non-string shape are skipped so the result holds
|
||||
// only the access levels actually granted.
|
||||
func brevoRoles(featureAccess map[string]json.RawMessage) []string {
|
||||
seen := make(map[string]struct{})
|
||||
|
||||
for _, raw := range featureAccess {
|
||||
var level string
|
||||
if err := json.Unmarshal(raw, &level); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
level = strings.TrimSpace(level)
|
||||
if level == "" || strings.EqualFold(level, "none") {
|
||||
continue
|
||||
}
|
||||
|
||||
seen[level] = struct{}{}
|
||||
}
|
||||
|
||||
roles := make([]string, 0, len(seen))
|
||||
for level := range seen {
|
||||
roles = append(roles, level)
|
||||
}
|
||||
|
||||
sort.Strings(roles)
|
||||
|
||||
return roles
|
||||
}
|
||||
107
pkg/accessreview/drivers/brevo_test.go
Normal file
107
pkg/accessreview/drivers/brevo_test.go
Normal file
@@ -0,0 +1,107 @@
|
||||
// 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"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.probo.inc/probo/pkg/coredata"
|
||||
)
|
||||
|
||||
func TestBrevoDriver(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rec := newRecorder(t, "testdata/brevo", "BREVO_API_KEY")
|
||||
// Brevo authenticates via the api-key header, not Authorization.
|
||||
client := newVCRClientWithHeader(rec, "api-key", os.Getenv("BREVO_API_KEY"))
|
||||
|
||||
driver := NewBrevoDriver(client)
|
||||
records, err := driver.ListAccounts(context.Background())
|
||||
require.NoError(t, err)
|
||||
require.Len(t, records, 3)
|
||||
|
||||
// Cassette recorded live (api-key header), then anonymized: the owner has
|
||||
// every feature at "owner"; the two members have crm/transactional "full"
|
||||
// and the rest "none".
|
||||
owner := records[0]
|
||||
assert.Equal(t, "000000000000000000000001", owner.ExternalID)
|
||||
assert.Equal(t, "owner@example.com", owner.Email)
|
||||
assert.Equal(t, "owner@example.com", owner.FullName)
|
||||
assert.True(t, owner.IsAdmin)
|
||||
require.NotNil(t, owner.Active)
|
||||
assert.True(t, *owner.Active)
|
||||
assert.Equal(t, []string{"owner"}, owner.Roles)
|
||||
assert.Equal(t, coredata.AccessReviewEntryAccountTypeUser, owner.AccountType)
|
||||
|
||||
// Non-owner; "none" levels filtered, the remaining "full" de-duplicated.
|
||||
member := records[1]
|
||||
assert.Equal(t, "000000000000000000000002", member.ExternalID)
|
||||
assert.False(t, member.IsAdmin)
|
||||
assert.Equal(t, []string{"full"}, member.Roles)
|
||||
require.NotNil(t, member.Active)
|
||||
assert.True(t, *member.Active)
|
||||
|
||||
// The third record is asserted too, so a swap or corruption is caught.
|
||||
viewer := records[2]
|
||||
assert.Equal(t, "000000000000000000000003", viewer.ExternalID)
|
||||
assert.Equal(t, "viewer@example.com", viewer.Email)
|
||||
assert.False(t, viewer.IsAdmin)
|
||||
assert.Equal(t, []string{"full"}, viewer.Roles)
|
||||
}
|
||||
|
||||
func TestBrevoExternalID(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// The stable id is preferred when present.
|
||||
assert.Equal(t, "abc123", brevoExternalID(brevoInvitedUser{ID: "abc123"}, "x@example.com"))
|
||||
// With no id, the email is the fallback.
|
||||
assert.Equal(t, "x@example.com", brevoExternalID(brevoInvitedUser{}, "x@example.com"))
|
||||
assert.Equal(t, "x@example.com", brevoExternalID(brevoInvitedUser{ID: " "}, "x@example.com"))
|
||||
}
|
||||
|
||||
func TestBrevoIsOwner(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// The live API returns a JSON boolean; older docs/SDK show a string.
|
||||
// Both must be tolerated.
|
||||
assert.True(t, brevoIsOwner(json.RawMessage(`true`)))
|
||||
assert.False(t, brevoIsOwner(json.RawMessage(`false`)))
|
||||
assert.True(t, brevoIsOwner(json.RawMessage(`"true"`)))
|
||||
assert.False(t, brevoIsOwner(json.RawMessage(`"false"`)))
|
||||
assert.False(t, brevoIsOwner(nil))
|
||||
}
|
||||
|
||||
func TestBrevoRoles(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Distinct non-"none" levels, sorted; "none" is filtered out.
|
||||
roles := brevoRoles(map[string]json.RawMessage{
|
||||
"marketing": json.RawMessage(`"owner"`),
|
||||
"conversations": json.RawMessage(`"owner"`),
|
||||
"crm": json.RawMessage(`"none"`),
|
||||
})
|
||||
assert.Equal(t, []string{"owner"}, roles)
|
||||
|
||||
// All "none" → no roles.
|
||||
assert.Empty(t, brevoRoles(map[string]json.RawMessage{"crm": json.RawMessage(`"none"`)}))
|
||||
|
||||
// A non-string shape is ignored rather than failing.
|
||||
assert.Empty(t, brevoRoles(map[string]json.RawMessage{"crm": json.RawMessage(`{"x":1}`)}))
|
||||
}
|
||||
@@ -17,6 +17,7 @@ package drivers
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.probo.inc/probo/pkg/coredata"
|
||||
@@ -83,3 +84,25 @@ func parseRFC3339Ptr(s string) *time.Time {
|
||||
|
||||
return &t
|
||||
}
|
||||
|
||||
// activeFromStatus maps a provider status string to the three-valued Active
|
||||
// signal for providers whose only "live" state is the literal "active" and
|
||||
// whose remaining status enum is not otherwise enumerated: "active" → active,
|
||||
// an empty status → nil (no signal), and any other non-empty status →
|
||||
// inactive. Used by drivers like Pylon and Brevo; a provider with a fully
|
||||
// known status enum (e.g. Render's active/inactive) maps its own values
|
||||
// explicitly instead, so an unrecognised value stays nil rather than false.
|
||||
func activeFromStatus(status string) *bool {
|
||||
switch strings.ToLower(strings.TrimSpace(status)) {
|
||||
case "active":
|
||||
active := true
|
||||
|
||||
return &active
|
||||
case "":
|
||||
return nil
|
||||
default:
|
||||
inactive := false
|
||||
|
||||
return &inactive
|
||||
}
|
||||
}
|
||||
|
||||
45
pkg/accessreview/drivers/driver_test.go
Normal file
45
pkg/accessreview/drivers/driver_test.go
Normal file
@@ -0,0 +1,45 @@
|
||||
// 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 (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestActiveFromStatus(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// "active" → active, case-insensitive.
|
||||
for _, s := range []string{"active", "ACTIVE", " Active "} {
|
||||
got := activeFromStatus(s)
|
||||
require.NotNilf(t, got, "status %q", s)
|
||||
assert.Truef(t, *got, "status %q", s)
|
||||
}
|
||||
|
||||
// Empty/whitespace status → no signal (nil).
|
||||
assert.Nil(t, activeFromStatus(""))
|
||||
assert.Nil(t, activeFromStatus(" "))
|
||||
|
||||
// Any other non-empty status is treated as inactive (Brevo "pending",
|
||||
// Pylon "deactivated", and any unrecognised future value).
|
||||
for _, s := range []string{"pending", "deactivated", "disabled", "whatever"} {
|
||||
got := activeFromStatus(s)
|
||||
require.NotNilf(t, got, "status %q", s)
|
||||
assert.Falsef(t, *got, "status %q", s)
|
||||
}
|
||||
}
|
||||
226
pkg/accessreview/drivers/incidentio.go
Normal file
226
pkg/accessreview/drivers/incidentio.go
Normal file
@@ -0,0 +1,226 @@
|
||||
// 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"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"go.probo.inc/probo/pkg/coredata"
|
||||
)
|
||||
|
||||
const (
|
||||
incidentIOUsersEndpoint = "https://api.incident.io/v2/users"
|
||||
// incidentIOPageSize is the page size requested from GET /v2/users (the
|
||||
// API defaults to 25 and accepts up to 10000).
|
||||
incidentIOPageSize = 100
|
||||
)
|
||||
|
||||
// IncidentIODriver lists the users of a single incident.io organization. The
|
||||
// API key (Bearer) is bound to one organization, so GET /v2/users returns
|
||||
// every user of that organization with no tenant selector.
|
||||
type IncidentIODriver struct {
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
var _ Driver = (*IncidentIODriver)(nil)
|
||||
|
||||
type incidentIORole struct {
|
||||
Name string `json:"name"`
|
||||
Slug string `json:"slug"`
|
||||
}
|
||||
|
||||
type incidentIOUser struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
// Role is the deprecated coarse role enum: owner / administrator /
|
||||
// responder / viewer / unset. base_role / custom_roles are the live
|
||||
// RBAC roles and take precedence when present.
|
||||
Role string `json:"role"`
|
||||
BaseRole *incidentIORole `json:"base_role"`
|
||||
CustomRoles []incidentIORole `json:"custom_roles"`
|
||||
}
|
||||
|
||||
type incidentIOUsersResponse struct {
|
||||
Users []incidentIOUser `json:"users"`
|
||||
PaginationMeta struct {
|
||||
After string `json:"after"`
|
||||
} `json:"pagination_meta"`
|
||||
}
|
||||
|
||||
func NewIncidentIODriver(httpClient *http.Client) *IncidentIODriver {
|
||||
return &IncidentIODriver{httpClient: httpClient}
|
||||
}
|
||||
|
||||
func (d *IncidentIODriver) ListAccounts(ctx context.Context) ([]AccountRecord, error) {
|
||||
var records []AccountRecord
|
||||
|
||||
after := ""
|
||||
|
||||
for range maxPaginationPages {
|
||||
resp, err := d.fetchUsersPage(ctx, after)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, u := range resp.Users {
|
||||
email := strings.TrimSpace(u.Email)
|
||||
if email == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
records = append(records, AccountRecord{
|
||||
Email: email,
|
||||
FullName: incidentIOFullName(u, email),
|
||||
Roles: incidentIORoles(u),
|
||||
IsAdmin: incidentIOIsAdmin(u),
|
||||
MFAStatus: coredata.MFAStatusUnknown,
|
||||
AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown,
|
||||
AccountType: coredata.AccessReviewEntryAccountTypeUser,
|
||||
ExternalID: strings.TrimSpace(u.ID),
|
||||
})
|
||||
}
|
||||
|
||||
// The `after` cursor is the authoritative end-of-results signal: stop
|
||||
// when it is empty. A short page is NOT treated as the end (incident.io
|
||||
// may return fewer than page_size rows while more pages remain). The
|
||||
// empty-page guard is only a backstop against an API that never clears
|
||||
// the cursor, so the loop cannot spin past the data.
|
||||
if resp.PaginationMeta.After == "" || len(resp.Users) == 0 {
|
||||
return records, nil
|
||||
}
|
||||
|
||||
after = resp.PaginationMeta.After
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("cannot list all incident.io users: %w", ErrPaginationLimitReached)
|
||||
}
|
||||
|
||||
func (d *IncidentIODriver) fetchUsersPage(ctx context.Context, after string) (*incidentIOUsersResponse, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, incidentIOUsersEndpoint, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot create incident.io users request: %w", err)
|
||||
}
|
||||
|
||||
q := req.URL.Query()
|
||||
q.Set("page_size", strconv.Itoa(incidentIOPageSize))
|
||||
|
||||
if after != "" {
|
||||
q.Set("after", after)
|
||||
}
|
||||
|
||||
req.URL.RawQuery = q.Encode()
|
||||
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
httpResp, err := d.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot execute incident.io users request: %w", err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
_ = httpResp.Body.Close()
|
||||
}()
|
||||
|
||||
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("cannot fetch incident.io users: unexpected status %d", httpResp.StatusCode)
|
||||
}
|
||||
|
||||
var resp incidentIOUsersResponse
|
||||
if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil {
|
||||
return nil, fmt.Errorf("cannot decode incident.io users response: %w", err)
|
||||
}
|
||||
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
func incidentIOFullName(u incidentIOUser, fallback string) string {
|
||||
if name := strings.TrimSpace(u.Name); name != "" {
|
||||
return name
|
||||
}
|
||||
|
||||
return fallback
|
||||
}
|
||||
|
||||
// incidentIORoles returns the user's roles, preferring the live RBAC roles
|
||||
// (base_role + custom_roles) and falling back to the deprecated `role` enum
|
||||
// only when no RBAC role is present.
|
||||
func incidentIORoles(u incidentIOUser) []string {
|
||||
roles := []string{}
|
||||
|
||||
if u.BaseRole != nil {
|
||||
if name := strings.TrimSpace(u.BaseRole.Name); name != "" {
|
||||
roles = append(roles, name)
|
||||
}
|
||||
}
|
||||
|
||||
for _, r := range u.CustomRoles {
|
||||
if name := strings.TrimSpace(r.Name); name != "" {
|
||||
roles = append(roles, name)
|
||||
}
|
||||
}
|
||||
|
||||
if len(roles) > 0 {
|
||||
return roles
|
||||
}
|
||||
|
||||
if name := incidentIODeprecatedRoleName(u.Role); name != "" {
|
||||
return []string{name}
|
||||
}
|
||||
|
||||
return []string{}
|
||||
}
|
||||
|
||||
// incidentIODeprecatedRoleName maps the deprecated coarse role enum to a
|
||||
// display label, returning "" for "unset" or an absent value.
|
||||
func incidentIODeprecatedRoleName(role string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(role)) {
|
||||
case "owner":
|
||||
return "Owner"
|
||||
case "administrator":
|
||||
return "Administrator"
|
||||
case "responder":
|
||||
return "Responder"
|
||||
case "viewer":
|
||||
return "Viewer"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// incidentIOIsAdmin reports whether the user holds an administrative role. It
|
||||
// prefers the live base_role slug and falls back to the deprecated role enum;
|
||||
// both "owner" and "administrator" are administrative.
|
||||
func incidentIOIsAdmin(u incidentIOUser) bool {
|
||||
if u.BaseRole != nil && strings.TrimSpace(u.BaseRole.Slug) != "" {
|
||||
return incidentIOAdminSlug(u.BaseRole.Slug)
|
||||
}
|
||||
|
||||
return incidentIOAdminSlug(u.Role)
|
||||
}
|
||||
|
||||
func incidentIOAdminSlug(slug string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(slug)) {
|
||||
case "owner", "administrator":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
108
pkg/accessreview/drivers/incidentio_test.go
Normal file
108
pkg/accessreview/drivers/incidentio_test.go
Normal file
@@ -0,0 +1,108 @@
|
||||
// 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"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.probo.inc/probo/pkg/coredata"
|
||||
)
|
||||
|
||||
func TestIncidentIODriver(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rec := newRecorder(t, "testdata/incidentio", "INCIDENT_IO_API_KEY")
|
||||
client := newVCRClient(rec, bearerAuth(os.Getenv("INCIDENT_IO_API_KEY")))
|
||||
|
||||
driver := NewIncidentIODriver(client)
|
||||
records, err := driver.ListAccounts(context.Background())
|
||||
require.NoError(t, err)
|
||||
// Three records spread across two pages: the cassette's first page is
|
||||
// short (2 < page_size) but carries a non-empty `after`, so getting all
|
||||
// three proves the driver follows the cursor instead of stopping early.
|
||||
require.Len(t, records, 3)
|
||||
|
||||
owner := records[0]
|
||||
assert.Equal(t, "01ABCOWNER", owner.ExternalID)
|
||||
assert.Equal(t, "lisa@example.com", owner.Email)
|
||||
assert.Equal(t, "Lisa Curtis", owner.FullName)
|
||||
assert.Equal(t, []string{"Owner"}, owner.Roles)
|
||||
assert.True(t, owner.IsAdmin)
|
||||
assert.Equal(t, coredata.AccessReviewEntryAccountTypeUser, owner.AccountType)
|
||||
// /v2/users carries no account-status field, so Active stays nil.
|
||||
assert.Nil(t, owner.Active)
|
||||
|
||||
// Live base_role + custom_roles are unioned; a responder is not an admin.
|
||||
responder := records[1]
|
||||
assert.Equal(t, []string{"Responder", "On-call Lead"}, responder.Roles)
|
||||
assert.False(t, responder.IsAdmin)
|
||||
|
||||
// base_role null → falls back to the deprecated role enum; empty name →
|
||||
// the display name falls back to the email.
|
||||
legacy := records[2]
|
||||
assert.Equal(t, "legacy-admin@example.com", legacy.FullName)
|
||||
assert.Equal(t, []string{"Administrator"}, legacy.Roles)
|
||||
assert.True(t, legacy.IsAdmin)
|
||||
}
|
||||
|
||||
func TestIncidentIORoles(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// base_role + custom_roles are unioned, base first.
|
||||
full := incidentIOUser{
|
||||
BaseRole: &incidentIORole{Name: "Owner", Slug: "owner"},
|
||||
CustomRoles: []incidentIORole{{Name: "On-call Lead", Slug: "on-call-lead"}},
|
||||
Role: "viewer",
|
||||
}
|
||||
assert.Equal(t, []string{"Owner", "On-call Lead"}, incidentIORoles(full))
|
||||
|
||||
// No live RBAC role → falls back to the deprecated enum.
|
||||
assert.Equal(t, []string{"Responder"}, incidentIORoles(incidentIOUser{Role: "responder"}))
|
||||
|
||||
// No role at all → empty slice.
|
||||
assert.Equal(t, []string{}, incidentIORoles(incidentIOUser{Role: "unset"}))
|
||||
}
|
||||
|
||||
func TestIncidentIOIsAdmin(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// The live base_role slug wins, including the "administrator" slug that
|
||||
// the cassette does not exercise.
|
||||
assert.True(t, incidentIOIsAdmin(incidentIOUser{BaseRole: &incidentIORole{Slug: "owner"}}))
|
||||
assert.True(t, incidentIOIsAdmin(incidentIOUser{BaseRole: &incidentIORole{Slug: "administrator"}}))
|
||||
assert.False(t, incidentIOIsAdmin(incidentIOUser{BaseRole: &incidentIORole{Slug: "responder"}}))
|
||||
// A non-admin base_role is NOT overridden by an admin deprecated role.
|
||||
assert.False(t, incidentIOIsAdmin(incidentIOUser{BaseRole: &incidentIORole{Slug: "viewer"}, Role: "administrator"}))
|
||||
// No base_role → falls back to the deprecated role enum.
|
||||
assert.True(t, incidentIOIsAdmin(incidentIOUser{Role: "administrator"}))
|
||||
assert.True(t, incidentIOIsAdmin(incidentIOUser{Role: "owner"}))
|
||||
assert.False(t, incidentIOIsAdmin(incidentIOUser{Role: "viewer"}))
|
||||
}
|
||||
|
||||
func TestIncidentIODeprecatedRoleName(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
assert.Equal(t, "Owner", incidentIODeprecatedRoleName("owner"))
|
||||
assert.Equal(t, "Administrator", incidentIODeprecatedRoleName("administrator"))
|
||||
assert.Equal(t, "Responder", incidentIODeprecatedRoleName("responder"))
|
||||
assert.Equal(t, "Viewer", incidentIODeprecatedRoleName("viewer"))
|
||||
// "unset" and an absent value map to no role.
|
||||
assert.Equal(t, "", incidentIODeprecatedRoleName("unset"))
|
||||
assert.Equal(t, "", incidentIODeprecatedRoleName(""))
|
||||
}
|
||||
171
pkg/accessreview/drivers/openrouter.go
Normal file
171
pkg/accessreview/drivers/openrouter.go
Normal file
@@ -0,0 +1,171 @@
|
||||
// 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"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"go.probo.inc/probo/pkg/coredata"
|
||||
)
|
||||
|
||||
const (
|
||||
openRouterMembersEndpoint = "https://openrouter.ai/api/v1/organization/members"
|
||||
// openRouterPageSize is the maximum page size GET /organization/members
|
||||
// accepts (limit must be between 1 and 100).
|
||||
openRouterPageSize = 100
|
||||
)
|
||||
|
||||
// OpenRouterDriver lists the members of a single OpenRouter organization. The
|
||||
// management (provisioning) API key is bound to one organization, so GET
|
||||
// /api/v1/organization/members returns every member of that organization
|
||||
// with no tenant selector.
|
||||
type OpenRouterDriver struct {
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
var _ Driver = (*OpenRouterDriver)(nil)
|
||||
|
||||
type openRouterMember struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
FirstName *string `json:"first_name"`
|
||||
LastName *string `json:"last_name"`
|
||||
// Role is OpenRouter's organization role enum: "org:admin" or
|
||||
// "org:member".
|
||||
Role string `json:"role"`
|
||||
}
|
||||
|
||||
type openRouterMembersResponse struct {
|
||||
Data []openRouterMember `json:"data"`
|
||||
TotalCount int `json:"total_count"`
|
||||
}
|
||||
|
||||
func NewOpenRouterDriver(httpClient *http.Client) *OpenRouterDriver {
|
||||
return &OpenRouterDriver{httpClient: httpClient}
|
||||
}
|
||||
|
||||
func (d *OpenRouterDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error) {
|
||||
var records []AccountRecord
|
||||
|
||||
offset := 0
|
||||
|
||||
for range maxPaginationPages {
|
||||
resp, err := d.fetchMembersPage(ctx, offset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, m := range resp.Data {
|
||||
email := strings.TrimSpace(m.Email)
|
||||
if email == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
records = append(records, AccountRecord{
|
||||
Email: email,
|
||||
FullName: openRouterFullName(m, email),
|
||||
Roles: openRouterRoles(m.Role),
|
||||
IsAdmin: m.Role == "org:admin",
|
||||
MFAStatus: coredata.MFAStatusUnknown,
|
||||
AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown,
|
||||
AccountType: coredata.AccessReviewEntryAccountTypeUser,
|
||||
ExternalID: strings.TrimSpace(m.ID),
|
||||
})
|
||||
}
|
||||
|
||||
offset += len(resp.Data)
|
||||
if len(resp.Data) < openRouterPageSize || offset >= resp.TotalCount {
|
||||
return records, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("cannot list all openrouter members: %w", ErrPaginationLimitReached)
|
||||
}
|
||||
|
||||
func (d *OpenRouterDriver) fetchMembersPage(ctx context.Context, offset int) (*openRouterMembersResponse, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, openRouterMembersEndpoint, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot create openrouter members request: %w", err)
|
||||
}
|
||||
|
||||
q := req.URL.Query()
|
||||
q.Set("limit", strconv.Itoa(openRouterPageSize))
|
||||
q.Set("offset", strconv.Itoa(offset))
|
||||
req.URL.RawQuery = q.Encode()
|
||||
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
httpResp, err := d.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot execute openrouter members request: %w", err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
_ = httpResp.Body.Close()
|
||||
}()
|
||||
|
||||
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("cannot fetch openrouter members: unexpected status %d", httpResp.StatusCode)
|
||||
}
|
||||
|
||||
var resp openRouterMembersResponse
|
||||
if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil {
|
||||
return nil, fmt.Errorf("cannot decode openrouter members response: %w", err)
|
||||
}
|
||||
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
func openRouterFullName(m openRouterMember, fallback string) string {
|
||||
first := ""
|
||||
if m.FirstName != nil {
|
||||
first = strings.TrimSpace(*m.FirstName)
|
||||
}
|
||||
|
||||
last := ""
|
||||
if m.LastName != nil {
|
||||
last = strings.TrimSpace(*m.LastName)
|
||||
}
|
||||
|
||||
full := strings.TrimSpace(first + " " + last)
|
||||
if full != "" {
|
||||
return full
|
||||
}
|
||||
|
||||
return fallback
|
||||
}
|
||||
|
||||
// openRouterRoles maps OpenRouter's organization role enum
|
||||
// (org:admin / org:member) to a display label, preserving any unknown
|
||||
// future role verbatim.
|
||||
func openRouterRoles(role string) []string {
|
||||
switch role {
|
||||
case "org:admin":
|
||||
return []string{"Admin"}
|
||||
case "org:member":
|
||||
return []string{"Member"}
|
||||
default:
|
||||
if strings.TrimSpace(role) != "" {
|
||||
return []string{role}
|
||||
}
|
||||
|
||||
return []string{}
|
||||
}
|
||||
}
|
||||
74
pkg/accessreview/drivers/openrouter_test.go
Normal file
74
pkg/accessreview/drivers/openrouter_test.go
Normal file
@@ -0,0 +1,74 @@
|
||||
// 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"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.probo.inc/probo/pkg/coredata"
|
||||
)
|
||||
|
||||
func TestOpenRouterDriver(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rec := newRecorder(t, "testdata/openrouter", "OPENROUTER_API_KEY")
|
||||
client := newVCRClient(rec, bearerAuth(os.Getenv("OPENROUTER_API_KEY")))
|
||||
|
||||
driver := NewOpenRouterDriver(client)
|
||||
records, err := driver.ListAccounts(context.Background())
|
||||
require.NoError(t, err)
|
||||
require.Len(t, records, 1)
|
||||
|
||||
// Cassette recorded live against an OpenRouter organization (single admin
|
||||
// member), then anonymized.
|
||||
admin := records[0]
|
||||
assert.Equal(t, "user_000000000000000000000admin", admin.ExternalID)
|
||||
assert.Equal(t, "ada.admin@example.com", admin.Email)
|
||||
assert.Equal(t, "Ada Admin", admin.FullName)
|
||||
assert.Equal(t, []string{"Admin"}, admin.Roles)
|
||||
assert.True(t, admin.IsAdmin)
|
||||
assert.Equal(t, coredata.AccessReviewEntryAccountTypeUser, admin.AccountType)
|
||||
// The members endpoint carries no account-status field, so Active stays nil.
|
||||
assert.Nil(t, admin.Active)
|
||||
}
|
||||
|
||||
func TestOpenRouterRoles(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
assert.Equal(t, []string{"Admin"}, openRouterRoles("org:admin"))
|
||||
assert.Equal(t, []string{"Member"}, openRouterRoles("org:member"))
|
||||
// An unknown future role is preserved verbatim; an empty role yields none.
|
||||
assert.Equal(t, []string{"org:billing"}, openRouterRoles("org:billing"))
|
||||
assert.Equal(t, []string{}, openRouterRoles(""))
|
||||
}
|
||||
|
||||
func TestOpenRouterFullName(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
first, last := "Bob", "Member"
|
||||
|
||||
// first + last.
|
||||
assert.Equal(t, "Bob Member", openRouterFullName(openRouterMember{FirstName: &first, LastName: &last}, "bob@example.com"))
|
||||
// last_name null → first name alone.
|
||||
assert.Equal(t, "Bob", openRouterFullName(openRouterMember{FirstName: &first}, "bob@example.com"))
|
||||
// first_name null → last name alone.
|
||||
assert.Equal(t, "Member", openRouterFullName(openRouterMember{LastName: &last}, "bob@example.com"))
|
||||
// both null → email fallback.
|
||||
assert.Equal(t, "carol@example.com", openRouterFullName(openRouterMember{}, "carol@example.com"))
|
||||
}
|
||||
254
pkg/accessreview/drivers/pylon.go
Normal file
254
pkg/accessreview/drivers/pylon.go
Normal file
@@ -0,0 +1,254 @@
|
||||
// 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"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"go.probo.inc/probo/pkg/coredata"
|
||||
)
|
||||
|
||||
const (
|
||||
pylonUsersEndpoint = "https://api.usepylon.com/users"
|
||||
pylonUserRolesEndpoint = "https://api.usepylon.com/user-roles"
|
||||
// pylonPageSize is the page size requested from the cursor-paginated
|
||||
// list endpoints. Pylon caps `limit` at 999 (it must be > 0 and < 1000);
|
||||
// 100 returns every member of typical organizations in one page.
|
||||
pylonPageSize = 100
|
||||
)
|
||||
|
||||
// PylonDriver lists the users (agents) of a single Pylon organization. The
|
||||
// API token (Bearer) is bound to one organization, so GET /users returns
|
||||
// every member of that organization with no tenant selector. Each user
|
||||
// carries an opaque role_id, which the driver resolves to a human-readable
|
||||
// role name via GET /user-roles.
|
||||
type PylonDriver struct {
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
var _ Driver = (*PylonDriver)(nil)
|
||||
|
||||
type pylonUser struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name"`
|
||||
RoleID string `json:"role_id"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
type pylonRole struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Slug string `json:"slug"`
|
||||
}
|
||||
|
||||
type pylonPagination struct {
|
||||
Cursor string `json:"cursor"`
|
||||
HasNextPage bool `json:"has_next_page"`
|
||||
}
|
||||
|
||||
type pylonUsersResponse struct {
|
||||
Data []pylonUser `json:"data"`
|
||||
Pagination pylonPagination `json:"pagination"`
|
||||
}
|
||||
|
||||
type pylonRolesResponse struct {
|
||||
Data []pylonRole `json:"data"`
|
||||
Pagination pylonPagination `json:"pagination"`
|
||||
}
|
||||
|
||||
func NewPylonDriver(httpClient *http.Client) *PylonDriver {
|
||||
return &PylonDriver{httpClient: httpClient}
|
||||
}
|
||||
|
||||
func (d *PylonDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error) {
|
||||
roles, err := d.fetchRoles(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var (
|
||||
records []AccountRecord
|
||||
cursor string
|
||||
)
|
||||
|
||||
for range maxPaginationPages {
|
||||
resp, err := d.fetchUsersPage(ctx, cursor)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, u := range resp.Data {
|
||||
email := strings.TrimSpace(u.Email)
|
||||
if email == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
role := roles[u.RoleID]
|
||||
|
||||
records = append(records, AccountRecord{
|
||||
Email: email,
|
||||
FullName: pylonFullName(u, email),
|
||||
Roles: pylonRoles(role),
|
||||
Active: activeFromStatus(u.Status),
|
||||
IsAdmin: pylonIsAdmin(role),
|
||||
MFAStatus: coredata.MFAStatusUnknown,
|
||||
AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown,
|
||||
AccountType: coredata.AccessReviewEntryAccountTypeUser,
|
||||
ExternalID: strings.TrimSpace(u.ID),
|
||||
})
|
||||
}
|
||||
|
||||
if !resp.Pagination.HasNextPage || resp.Pagination.Cursor == "" {
|
||||
return records, nil
|
||||
}
|
||||
|
||||
cursor = resp.Pagination.Cursor
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("cannot list all pylon users: %w", ErrPaginationLimitReached)
|
||||
}
|
||||
|
||||
// fetchRoles loads the organization's role catalogue once, keyed by role ID,
|
||||
// so each user's opaque role_id can be resolved to a role name and admin
|
||||
// classification.
|
||||
func (d *PylonDriver) fetchRoles(ctx context.Context) (map[string]pylonRole, error) {
|
||||
roles := make(map[string]pylonRole)
|
||||
|
||||
cursor := ""
|
||||
|
||||
for range maxPaginationPages {
|
||||
resp, err := d.fetchRolesPage(ctx, cursor)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, r := range resp.Data {
|
||||
roles[r.ID] = r
|
||||
}
|
||||
|
||||
if !resp.Pagination.HasNextPage || resp.Pagination.Cursor == "" {
|
||||
return roles, nil
|
||||
}
|
||||
|
||||
cursor = resp.Pagination.Cursor
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("cannot list all pylon user-roles: %w", ErrPaginationLimitReached)
|
||||
}
|
||||
|
||||
func (d *PylonDriver) fetchUsersPage(ctx context.Context, cursor string) (*pylonUsersResponse, error) {
|
||||
httpResp, err := d.fetchPage(ctx, pylonUsersEndpoint, cursor)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
defer func() {
|
||||
_ = httpResp.Body.Close()
|
||||
}()
|
||||
|
||||
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("cannot fetch pylon users: unexpected status %d", httpResp.StatusCode)
|
||||
}
|
||||
|
||||
var resp pylonUsersResponse
|
||||
if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil {
|
||||
return nil, fmt.Errorf("cannot decode pylon users response: %w", err)
|
||||
}
|
||||
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
func (d *PylonDriver) fetchRolesPage(ctx context.Context, cursor string) (*pylonRolesResponse, error) {
|
||||
httpResp, err := d.fetchPage(ctx, pylonUserRolesEndpoint, cursor)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
defer func() {
|
||||
_ = httpResp.Body.Close()
|
||||
}()
|
||||
|
||||
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("cannot fetch pylon user-roles: unexpected status %d", httpResp.StatusCode)
|
||||
}
|
||||
|
||||
var resp pylonRolesResponse
|
||||
if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil {
|
||||
return nil, fmt.Errorf("cannot decode pylon user-roles response: %w", err)
|
||||
}
|
||||
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
func (d *PylonDriver) fetchPage(ctx context.Context, endpoint, cursor string) (*http.Response, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot create pylon request: %w", err)
|
||||
}
|
||||
|
||||
q := req.URL.Query()
|
||||
q.Set("limit", strconv.Itoa(pylonPageSize))
|
||||
|
||||
if cursor != "" {
|
||||
q.Set("cursor", cursor)
|
||||
}
|
||||
|
||||
req.URL.RawQuery = q.Encode()
|
||||
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
httpResp, err := d.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot execute pylon request: %w", err)
|
||||
}
|
||||
|
||||
return httpResp, nil
|
||||
}
|
||||
|
||||
func pylonFullName(u pylonUser, fallback string) string {
|
||||
if name := strings.TrimSpace(u.Name); name != "" {
|
||||
return name
|
||||
}
|
||||
|
||||
return fallback
|
||||
}
|
||||
|
||||
// pylonRoles returns the user's role as a single-element slice using the
|
||||
// resolved role name, or an empty slice when the role_id did not resolve.
|
||||
func pylonRoles(role pylonRole) []string {
|
||||
if name := strings.TrimSpace(role.Name); name != "" {
|
||||
return []string{name}
|
||||
}
|
||||
|
||||
return []string{}
|
||||
}
|
||||
|
||||
// pylonIsAdmin reports whether the resolved role is Pylon's built-in Admin
|
||||
// role. Pylon ships two default roles (Member and Admin); the match is on
|
||||
// the stable slug, falling back to an exact (case-insensitive) name match,
|
||||
// so a custom role merely containing "admin" is not auto-classified.
|
||||
func pylonIsAdmin(role pylonRole) bool {
|
||||
if slug := strings.TrimSpace(role.Slug); slug != "" {
|
||||
return strings.EqualFold(slug, "admin")
|
||||
}
|
||||
|
||||
return strings.EqualFold(strings.TrimSpace(role.Name), "Admin")
|
||||
}
|
||||
76
pkg/accessreview/drivers/pylon_test.go
Normal file
76
pkg/accessreview/drivers/pylon_test.go
Normal file
@@ -0,0 +1,76 @@
|
||||
// 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"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.probo.inc/probo/pkg/coredata"
|
||||
)
|
||||
|
||||
func TestPylonDriver(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rec := newRecorder(t, "testdata/pylon", "PYLON_API_KEY")
|
||||
client := newVCRClient(rec, bearerAuth(os.Getenv("PYLON_API_KEY")))
|
||||
|
||||
driver := NewPylonDriver(client)
|
||||
records, err := driver.ListAccounts(context.Background())
|
||||
require.NoError(t, err)
|
||||
require.Len(t, records, 3)
|
||||
|
||||
admin := records[0]
|
||||
assert.Equal(t, "user_1", admin.ExternalID)
|
||||
assert.Equal(t, "alice@example.com", admin.Email)
|
||||
assert.Equal(t, "Alice Admin", admin.FullName)
|
||||
// role_id "role_admin" resolved through GET /user-roles.
|
||||
assert.Equal(t, []string{"Admin"}, admin.Roles)
|
||||
assert.True(t, admin.IsAdmin)
|
||||
require.NotNil(t, admin.Active)
|
||||
assert.True(t, *admin.Active)
|
||||
assert.Equal(t, coredata.AccessReviewEntryAccountTypeUser, admin.AccountType)
|
||||
|
||||
member := records[1]
|
||||
assert.Equal(t, []string{"Member"}, member.Roles)
|
||||
assert.False(t, member.IsAdmin)
|
||||
|
||||
// No name → display name falls back to the email; "deactivated" status →
|
||||
// Active false.
|
||||
deactivated := records[2]
|
||||
assert.Equal(t, "carol@example.com", deactivated.FullName)
|
||||
assert.Equal(t, []string{"Member"}, deactivated.Roles)
|
||||
require.NotNil(t, deactivated.Active)
|
||||
assert.False(t, *deactivated.Active)
|
||||
}
|
||||
|
||||
func TestPylonIsAdmin(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// The stable slug is preferred when present.
|
||||
assert.True(t, pylonIsAdmin(pylonRole{Slug: "admin", Name: "Admin"}))
|
||||
assert.False(t, pylonIsAdmin(pylonRole{Slug: "member", Name: "Member"}))
|
||||
// A custom role named like an admin but with a non-admin slug is NOT an
|
||||
// admin — the slug wins.
|
||||
assert.False(t, pylonIsAdmin(pylonRole{Slug: "billing", Name: "Billing Admin"}))
|
||||
// With no slug, the exact (case-insensitive) name is used.
|
||||
assert.True(t, pylonIsAdmin(pylonRole{Name: "Admin"}))
|
||||
assert.False(t, pylonIsAdmin(pylonRole{Name: "Billing Admin"}))
|
||||
// An unresolved role (zero value: role_id not in the catalogue) is not admin.
|
||||
assert.False(t, pylonIsAdmin(pylonRole{}))
|
||||
}
|
||||
35
pkg/accessreview/drivers/testdata/brevo.yaml
vendored
Normal file
35
pkg/accessreview/drivers/testdata/brevo.yaml
vendored
Normal file
@@ -0,0 +1,35 @@
|
||||
---
|
||||
# Recorded live against GET /v3/organization/invited/users (Brevo, api-key
|
||||
# header) on 2026-06-24, then anonymized: real ids, emails and locales are
|
||||
# replaced with synthetic values, while the {users} wrapper and member shape
|
||||
# are the verbatim live response — note is_owner is a JSON boolean (not the
|
||||
# string the docs/SDK show), an `id` is present, and feature_access carries
|
||||
# more keys than documented (transactional/phone/meetings/sequences) with a
|
||||
# "full" level. The api-key header is stripped on save.
|
||||
version: 2
|
||||
interactions:
|
||||
- id: 0
|
||||
request:
|
||||
proto: HTTP/1.1
|
||||
proto_major: 1
|
||||
proto_minor: 1
|
||||
content_length: 0
|
||||
host: api.brevo.com
|
||||
headers:
|
||||
Accept:
|
||||
- application/json
|
||||
url: https://api.brevo.com/v3/organization/invited/users
|
||||
method: GET
|
||||
response:
|
||||
proto: HTTP/2.0
|
||||
proto_major: 2
|
||||
proto_minor: 0
|
||||
content_length: -1
|
||||
uncompressed: true
|
||||
body: '{"users":[{"id":"000000000000000000000001","email":"owner@example.com","is_owner":true,"status":"active","feature_access":{"marketing":"owner","conversations":"owner","crm":"owner","transactional":"owner","phone":"owner"},"locale":"fr_FR"},{"id":"000000000000000000000002","email":"member@example.com","is_owner":false,"status":"active","feature_access":{"marketing":"none","conversations":"none","crm":"full","transactional":"full","phone":"none","meetings":"none","sequences":"none"},"locale":"fr_FR"},{"id":"000000000000000000000003","email":"viewer@example.com","is_owner":false,"status":"active","feature_access":{"marketing":"none","conversations":"none","crm":"full","transactional":"full","phone":"none","meetings":"none","sequences":"none"},"locale":"en_US"}]}'
|
||||
headers:
|
||||
Content-Type:
|
||||
- application/json
|
||||
status: 200 OK
|
||||
code: 200
|
||||
duration: 124ms
|
||||
71
pkg/accessreview/drivers/testdata/incidentio.yaml
vendored
Normal file
71
pkg/accessreview/drivers/testdata/incidentio.yaml
vendored
Normal file
@@ -0,0 +1,71 @@
|
||||
---
|
||||
# Hand-authored fixture for GET /v2/users against an incident.io
|
||||
# organization. The user object shape (id, name, email, role, base_role,
|
||||
# custom_roles) and the {users, pagination_meta} wrapper mirror the
|
||||
# documented response. Synthetic IDs/emails only.
|
||||
#
|
||||
# Two interactions deliberately split three users across two pages, where the
|
||||
# FIRST page returns fewer than page_size rows while still handing back a
|
||||
# non-empty `after` cursor. This regression-guards the pagination terminator:
|
||||
# the driver must follow the cursor (and return all three users) rather than
|
||||
# stop early on the short first page.
|
||||
version: 2
|
||||
interactions:
|
||||
- id: 0
|
||||
request:
|
||||
proto: HTTP/1.1
|
||||
proto_major: 1
|
||||
proto_minor: 1
|
||||
content_length: 0
|
||||
host: api.incident.io
|
||||
form:
|
||||
page_size:
|
||||
- "100"
|
||||
headers:
|
||||
Accept:
|
||||
- application/json
|
||||
url: https://api.incident.io/v2/users?page_size=100
|
||||
method: GET
|
||||
response:
|
||||
proto: HTTP/2.0
|
||||
proto_major: 2
|
||||
proto_minor: 0
|
||||
content_length: -1
|
||||
uncompressed: true
|
||||
body: '{"users":[{"id":"01ABCOWNER","name":"Lisa Curtis","email":"lisa@example.com","role":"viewer","base_role":{"id":"r1","name":"Owner","slug":"owner"},"custom_roles":[],"slack_user_id":"U01"},{"id":"01DEFRESP","name":"Sam Responder","email":"sam@example.com","role":"responder","base_role":{"id":"r2","name":"Responder","slug":"responder"},"custom_roles":[{"id":"c1","name":"On-call Lead","slug":"on-call-lead"}],"slack_user_id":"U02"}],"pagination_meta":{"after":"01PAGE2CURSOR0000000000000","page_size":100,"total_record_count":3}}'
|
||||
headers:
|
||||
Content-Type:
|
||||
- application/json
|
||||
status: 200 OK
|
||||
code: 200
|
||||
duration: 150ms
|
||||
- id: 1
|
||||
request:
|
||||
proto: HTTP/1.1
|
||||
proto_major: 1
|
||||
proto_minor: 1
|
||||
content_length: 0
|
||||
host: api.incident.io
|
||||
form:
|
||||
after:
|
||||
- "01PAGE2CURSOR0000000000000"
|
||||
page_size:
|
||||
- "100"
|
||||
headers:
|
||||
Accept:
|
||||
- application/json
|
||||
url: https://api.incident.io/v2/users?after=01PAGE2CURSOR0000000000000&page_size=100
|
||||
method: GET
|
||||
response:
|
||||
proto: HTTP/2.0
|
||||
proto_major: 2
|
||||
proto_minor: 0
|
||||
content_length: -1
|
||||
uncompressed: true
|
||||
body: '{"users":[{"id":"01GHIADMIN","name":"","email":"legacy-admin@example.com","role":"administrator","base_role":null,"custom_roles":[],"slack_user_id":""}],"pagination_meta":{"after":"","page_size":100,"total_record_count":3}}'
|
||||
headers:
|
||||
Content-Type:
|
||||
- application/json
|
||||
status: 200 OK
|
||||
code: 200
|
||||
duration: 140ms
|
||||
40
pkg/accessreview/drivers/testdata/openrouter.yaml
vendored
Normal file
40
pkg/accessreview/drivers/testdata/openrouter.yaml
vendored
Normal file
@@ -0,0 +1,40 @@
|
||||
---
|
||||
# Recorded live against GET /api/v1/organization/members (with an OpenRouter
|
||||
# organization management key) on 2026-06-24, then anonymized: the real
|
||||
# member's id, name and email are replaced with synthetic values, while the
|
||||
# {data, total_count} wrapper and member shape (id, first_name, last_name,
|
||||
# email, role) are the verbatim live response shape. The Authorization header
|
||||
# is stripped on save. The org:member role and null-name fallbacks (absent
|
||||
# from this single-admin org) are covered by unit tests in the driver test.
|
||||
version: 2
|
||||
interactions:
|
||||
- id: 0
|
||||
request:
|
||||
proto: HTTP/1.1
|
||||
proto_major: 1
|
||||
proto_minor: 1
|
||||
content_length: 0
|
||||
host: openrouter.ai
|
||||
form:
|
||||
limit:
|
||||
- "100"
|
||||
offset:
|
||||
- "0"
|
||||
headers:
|
||||
Accept:
|
||||
- application/json
|
||||
url: https://openrouter.ai/api/v1/organization/members?limit=100&offset=0
|
||||
method: GET
|
||||
response:
|
||||
proto: HTTP/2.0
|
||||
proto_major: 2
|
||||
proto_minor: 0
|
||||
content_length: -1
|
||||
uncompressed: true
|
||||
body: '{"data":[{"id":"user_000000000000000000000admin","first_name":"Ada","last_name":"Admin","email":"ada.admin@example.com","role":"org:admin"}],"total_count":1}'
|
||||
headers:
|
||||
Content-Type:
|
||||
- application/json
|
||||
status: 200 OK
|
||||
code: 200
|
||||
duration: 454ms
|
||||
64
pkg/accessreview/drivers/testdata/pylon.yaml
vendored
Normal file
64
pkg/accessreview/drivers/testdata/pylon.yaml
vendored
Normal file
@@ -0,0 +1,64 @@
|
||||
---
|
||||
# Hand-authored fixture for the Pylon access-review driver. The driver first
|
||||
# resolves the organization's role catalogue (GET /user-roles), then lists
|
||||
# members (GET /users) and maps each user's opaque role_id to a role name.
|
||||
# The user/role object shapes mirror the documented OpenAPI schema. Synthetic
|
||||
# IDs/emails only.
|
||||
version: 2
|
||||
interactions:
|
||||
- id: 0
|
||||
request:
|
||||
proto: HTTP/1.1
|
||||
proto_major: 1
|
||||
proto_minor: 1
|
||||
content_length: 0
|
||||
host: api.usepylon.com
|
||||
form:
|
||||
limit:
|
||||
- "100"
|
||||
headers:
|
||||
Accept:
|
||||
- application/json
|
||||
url: https://api.usepylon.com/user-roles?limit=100
|
||||
method: GET
|
||||
response:
|
||||
proto: HTTP/2.0
|
||||
proto_major: 2
|
||||
proto_minor: 0
|
||||
content_length: -1
|
||||
uncompressed: true
|
||||
body: '{"data":[{"id":"role_admin","name":"Admin","slug":"admin"},{"id":"role_member","name":"Member","slug":"member"}],"pagination":{"cursor":"","has_next_page":false},"request_id":"req_roles_1"}'
|
||||
headers:
|
||||
Content-Type:
|
||||
- application/json
|
||||
status: 200 OK
|
||||
code: 200
|
||||
duration: 120ms
|
||||
- id: 1
|
||||
request:
|
||||
proto: HTTP/1.1
|
||||
proto_major: 1
|
||||
proto_minor: 1
|
||||
content_length: 0
|
||||
host: api.usepylon.com
|
||||
form:
|
||||
limit:
|
||||
- "100"
|
||||
headers:
|
||||
Accept:
|
||||
- application/json
|
||||
url: https://api.usepylon.com/users?limit=100
|
||||
method: GET
|
||||
response:
|
||||
proto: HTTP/2.0
|
||||
proto_major: 2
|
||||
proto_minor: 0
|
||||
content_length: -1
|
||||
uncompressed: true
|
||||
body: '{"data":[{"id":"user_1","email":"alice@example.com","name":"Alice Admin","role_id":"role_admin","status":"active"},{"id":"user_2","email":"bob@example.com","name":"Bob Member","role_id":"role_member","status":"active"},{"id":"user_3","email":"carol@example.com","name":"","role_id":"role_member","status":"deactivated"}],"pagination":{"cursor":"","has_next_page":false},"request_id":"req_users_1"}'
|
||||
headers:
|
||||
Content-Type:
|
||||
- application/json
|
||||
status: 200 OK
|
||||
code: 200
|
||||
duration: 140ms
|
||||
@@ -51,12 +51,13 @@ func newRecorder(t *testing.T, cassettePath string, envVar string) *recorder.Rec
|
||||
)),
|
||||
recorder.WithHook(func(i *cassette.Interaction) error {
|
||||
i.Request.Headers.Del("Authorization")
|
||||
// Providers like Anthropic (x-api-key) and SigNoz
|
||||
// (SIGNOZ-API-KEY) authenticate via a custom header rather
|
||||
// than Authorization; strip those too so a re-record never
|
||||
// persists a raw key.
|
||||
// Providers like Anthropic (x-api-key), SigNoz
|
||||
// (SIGNOZ-API-KEY) and Brevo (api-key) authenticate via a
|
||||
// custom header rather than Authorization; strip those too so a
|
||||
// re-record never persists a raw key.
|
||||
i.Request.Headers.Del("X-Api-Key")
|
||||
i.Request.Headers.Del("Signoz-Api-Key")
|
||||
i.Request.Headers.Del("Api-Key")
|
||||
|
||||
return nil
|
||||
}, recorder.BeforeSaveHook),
|
||||
|
||||
49
pkg/connector/provider/brevo.go
Normal file
49
pkg/connector/provider/brevo.go
Normal file
@@ -0,0 +1,49 @@
|
||||
// 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"
|
||||
"net/http"
|
||||
|
||||
"go.gearno.de/kit/log"
|
||||
"go.probo.inc/probo/pkg/accessreview/drivers"
|
||||
"go.probo.inc/probo/pkg/coredata"
|
||||
)
|
||||
|
||||
func brevoRegistration() *Registration {
|
||||
return &Registration{
|
||||
Provider: coredata.ConnectorProviderBrevo,
|
||||
DisplayName: "Brevo",
|
||||
SupportsAPIKey: true,
|
||||
// Brevo authenticates with an API key sent in the api-key header
|
||||
// rather than Authorization: Bearer. APIKeyHeader makes the
|
||||
// APIKeyConnection send api-key instead and omit Authorization. There
|
||||
// is no OAuth2 flow needed: the key is bound to one Brevo account, so
|
||||
// there is nothing to pick (Pattern 3): no settings struct, no
|
||||
// picker.
|
||||
APIKeyHeader: "api-key",
|
||||
// ProbeURL lets the connection-status check confirm the key with a
|
||||
// lightweight GET; the transport attaches the api-key header and a
|
||||
// dead key returns 401/403.
|
||||
ProbeURL: "https://api.brevo.com/v3/organization/invited/users",
|
||||
//
|
||||
// No NewNameResolver: the invited-users endpoint carries no account
|
||||
// name, so the source keeps its generic name.
|
||||
NewDriver: func(_ context.Context, c *http.Client, _ *coredata.Connector, _ *log.Logger) (drivers.Driver, error) {
|
||||
return drivers.NewBrevoDriver(c), nil
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,7 @@ func NewBuiltinRegistry() *Registry {
|
||||
asanaRegistration(),
|
||||
betterStackRegistration(),
|
||||
bitbucketRegistration(),
|
||||
brevoRegistration(),
|
||||
brexRegistration(),
|
||||
clerkRegistration(),
|
||||
clickhouseRegistration(),
|
||||
@@ -42,6 +43,7 @@ func NewBuiltinRegistry() *Registry {
|
||||
googleWorkspaceRegistration(),
|
||||
herokuRegistration(),
|
||||
hubspotRegistration(),
|
||||
incidentioRegistration(),
|
||||
intercomRegistration(),
|
||||
langfuseRegistration(),
|
||||
linearRegistration(),
|
||||
@@ -55,8 +57,10 @@ func NewBuiltinRegistry() *Registry {
|
||||
oktaRegistration(),
|
||||
onePasswordRegistration(),
|
||||
openaiRegistration(),
|
||||
openrouterRegistration(),
|
||||
posthogRegistration(),
|
||||
pagerdutyRegistration(),
|
||||
pylonRegistration(),
|
||||
qoveryRegistration(),
|
||||
renderRegistration(),
|
||||
resendRegistration(),
|
||||
|
||||
49
pkg/connector/provider/incidentio.go
Normal file
49
pkg/connector/provider/incidentio.go
Normal file
@@ -0,0 +1,49 @@
|
||||
// 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"
|
||||
"net/http"
|
||||
|
||||
"go.gearno.de/kit/log"
|
||||
"go.probo.inc/probo/pkg/accessreview/drivers"
|
||||
"go.probo.inc/probo/pkg/coredata"
|
||||
)
|
||||
|
||||
func incidentioRegistration() *Registration {
|
||||
return &Registration{
|
||||
Provider: coredata.ConnectorProviderIncidentIO,
|
||||
DisplayName: "incident.io",
|
||||
SupportsAPIKey: true,
|
||||
// incident.io publishes an OAuth2 flow, but it is outbound-only (for
|
||||
// incident.io to call other apps), so access review authenticates
|
||||
// with an API key presented as Authorization: Bearer, the default
|
||||
// APIKeyConnection scheme. The key is bound to one organization, so
|
||||
// there is nothing to pick (Pattern 3): no settings struct, no
|
||||
// picker.
|
||||
//
|
||||
// ProbeURL lets the connection-status check confirm the key with a
|
||||
// lightweight GET; the transport attaches the Bearer token and a dead
|
||||
// key returns 401/403.
|
||||
ProbeURL: "https://api.incident.io/v2/users?page_size=1",
|
||||
//
|
||||
// No NewNameResolver: GET /v2/users carries no organization name, so
|
||||
// the source keeps its generic name.
|
||||
NewDriver: func(_ context.Context, c *http.Client, _ *coredata.Connector, _ *log.Logger) (drivers.Driver, error) {
|
||||
return drivers.NewIncidentIODriver(c), nil
|
||||
},
|
||||
}
|
||||
}
|
||||
50
pkg/connector/provider/openrouter.go
Normal file
50
pkg/connector/provider/openrouter.go
Normal file
@@ -0,0 +1,50 @@
|
||||
// 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"
|
||||
"net/http"
|
||||
|
||||
"go.gearno.de/kit/log"
|
||||
"go.probo.inc/probo/pkg/accessreview/drivers"
|
||||
"go.probo.inc/probo/pkg/coredata"
|
||||
)
|
||||
|
||||
func openrouterRegistration() *Registration {
|
||||
return &Registration{
|
||||
Provider: coredata.ConnectorProviderOpenRouter,
|
||||
DisplayName: "OpenRouter",
|
||||
SupportsAPIKey: true,
|
||||
// OpenRouter authenticates with an organization management
|
||||
// (provisioning) API key presented as Authorization: Bearer, the
|
||||
// default APIKeyConnection scheme. The key is bound to one
|
||||
// organization, so there is nothing to pick (Pattern 3): no settings
|
||||
// struct, no picker.
|
||||
//
|
||||
// Probe confirms the key with a lightweight GET against the members
|
||||
// endpoint; the transport attaches the Bearer token. probeOpenRouter
|
||||
// rejects 401/403 (revoked/invalid key) and also 404, which a valid
|
||||
// but personal (non-organization) key returns — so a key that cannot
|
||||
// list members shows as not-connected rather than failing later.
|
||||
Probe: probeOpenRouter,
|
||||
//
|
||||
// No NewNameResolver: the members endpoint carries no organization
|
||||
// name, so the source keeps its generic name.
|
||||
NewDriver: func(_ context.Context, c *http.Client, _ *coredata.Connector, _ *log.Logger) (drivers.Driver, error) {
|
||||
return drivers.NewOpenRouterDriver(c), nil
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"go.probo.inc/probo/pkg/connector"
|
||||
@@ -29,14 +30,15 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
anthropicAPIVersion = "2023-06-01"
|
||||
anthropicUsersProbeURL = "https://api.anthropic.com/v1/organizations/users?limit=1"
|
||||
herokuAccountProbeURL = "https://api.heroku.com/account"
|
||||
linearGraphQLEndpoint = "https://api.linear.app/graphql"
|
||||
mondayGraphQLEndpoint = "https://api.monday.com/v2"
|
||||
posthogOrganizationPath = "/api/organizations/@current/"
|
||||
posthogUSBaseURL = "https://us.posthog.com"
|
||||
posthogEUBaseURL = "https://eu.posthog.com"
|
||||
anthropicAPIVersion = "2023-06-01"
|
||||
anthropicUsersProbeURL = "https://api.anthropic.com/v1/organizations/users?limit=1"
|
||||
herokuAccountProbeURL = "https://api.heroku.com/account"
|
||||
openRouterMembersProbeURL = "https://openrouter.ai/api/v1/organization/members?limit=1"
|
||||
linearGraphQLEndpoint = "https://api.linear.app/graphql"
|
||||
mondayGraphQLEndpoint = "https://api.monday.com/v2"
|
||||
posthogOrganizationPath = "/api/organizations/@current/"
|
||||
posthogUSBaseURL = "https://us.posthog.com"
|
||||
posthogEUBaseURL = "https://eu.posthog.com"
|
||||
)
|
||||
|
||||
// ProbeConnection verifies that the connector credential is accepted by the
|
||||
@@ -112,7 +114,12 @@ func probePOSTJSON(
|
||||
return doProbeRequest(httpClient, req)
|
||||
}
|
||||
|
||||
func doProbeRequest(httpClient *http.Client, req *http.Request) error {
|
||||
// doProbeRequest executes a probe request and maps the status to a verdict:
|
||||
// 401/403 always mean the credential is rejected, any 2xx/other status means
|
||||
// connected. extraReject lets a provider add statuses that also mean a hard
|
||||
// rejection (e.g. OpenRouter's 404 for a non-organization key); pass none for
|
||||
// the default 401/403-only contract.
|
||||
func doProbeRequest(httpClient *http.Client, req *http.Request, extraReject ...int) error {
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("probe request failed: %w", err)
|
||||
@@ -123,7 +130,9 @@ func doProbeRequest(httpClient *http.Client, req *http.Request) error {
|
||||
_ = resp.Body.Close()
|
||||
}()
|
||||
|
||||
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
|
||||
if resp.StatusCode == http.StatusUnauthorized ||
|
||||
resp.StatusCode == http.StatusForbidden ||
|
||||
slices.Contains(extraReject, resp.StatusCode) {
|
||||
return fmt.Errorf("credential rejected: status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
@@ -431,6 +440,27 @@ func probeHeroku(
|
||||
return doProbeRequest(httpClient, req)
|
||||
}
|
||||
|
||||
// probeOpenRouter verifies an OpenRouter management key. Beyond the usual
|
||||
// 401/403, it treats 404 as a rejection too: a personal (non-organization)
|
||||
// key authenticates but the members endpoint returns 404 "This endpoint is
|
||||
// only available for organization accounts" (verified live) — a permanent,
|
||||
// not transient, signal that the connector can never list anyone, so it
|
||||
// surfaces at connection time instead of failing a campaign later.
|
||||
func probeOpenRouter(
|
||||
ctx context.Context,
|
||||
httpClient *http.Client,
|
||||
_ *coredata.Connector,
|
||||
) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, openRouterMembersProbeURL, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot create probe request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
return doProbeRequest(httpClient, req, http.StatusNotFound)
|
||||
}
|
||||
|
||||
func probePostHog(
|
||||
ctx context.Context,
|
||||
httpClient *http.Client,
|
||||
|
||||
@@ -112,6 +112,48 @@ func TestBuildPostHogProbeURL(t *testing.T) {
|
||||
assert.Equal(t, "https://us.posthog.com/api/organizations/@current/", probeURL)
|
||||
}
|
||||
|
||||
func TestProbeOpenRouter(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// probeOpenRouter must reject 401/403 (bad key) and 404 (a valid but
|
||||
// personal/non-organization key, which the members endpoint rejects with
|
||||
// 404), while letting 2xx pass.
|
||||
cases := []struct {
|
||||
name string
|
||||
status int
|
||||
wantReject bool
|
||||
}{
|
||||
{"valid management key", http.StatusOK, false},
|
||||
{"revoked key", http.StatusUnauthorized, true},
|
||||
{"forbidden key", http.StatusForbidden, true},
|
||||
{"personal (non-org) key", http.StatusNotFound, true},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var gotURL string
|
||||
|
||||
client := &http.Client{Transport: probeRoundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
gotURL = r.URL.String()
|
||||
|
||||
return &http.Response{StatusCode: tc.status, Body: http.NoBody, Header: make(http.Header)}, nil
|
||||
})}
|
||||
|
||||
err := probeOpenRouter(context.Background(), client, &coredata.Connector{Provider: coredata.ConnectorProviderOpenRouter})
|
||||
|
||||
assert.Equal(t, "https://openrouter.ai/api/v1/organization/members?limit=1", gotURL)
|
||||
|
||||
if tc.wantReject {
|
||||
require.Error(t, err)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProbeHeroku(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
49
pkg/connector/provider/pylon.go
Normal file
49
pkg/connector/provider/pylon.go
Normal file
@@ -0,0 +1,49 @@
|
||||
// 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"
|
||||
"net/http"
|
||||
|
||||
"go.gearno.de/kit/log"
|
||||
"go.probo.inc/probo/pkg/accessreview/drivers"
|
||||
"go.probo.inc/probo/pkg/coredata"
|
||||
)
|
||||
|
||||
func pylonRegistration() *Registration {
|
||||
return &Registration{
|
||||
Provider: coredata.ConnectorProviderPylon,
|
||||
DisplayName: "Pylon",
|
||||
SupportsAPIKey: true,
|
||||
// Pylon authenticates with an account API token presented as
|
||||
// Authorization: Bearer, the default APIKeyConnection scheme. There
|
||||
// is no third-party OAuth2 flow for the Users API. The token is bound
|
||||
// to one Pylon organization, so there is nothing to pick (Pattern 3):
|
||||
// no settings struct, no picker, no SetOrganizationSettings.
|
||||
//
|
||||
// ProbeURL lets the connection-status check confirm the token is live
|
||||
// with a lightweight GET; the transport attaches the Bearer token and
|
||||
// a dead token returns 401/403.
|
||||
ProbeURL: "https://api.usepylon.com/users?limit=1",
|
||||
//
|
||||
// No NewNameResolver: GET /users carries no organization name, so the
|
||||
// source keeps its generic name (the source-name worker degrades
|
||||
// gracefully).
|
||||
NewDriver: func(_ context.Context, c *http.Client, _ *coredata.Connector, _ *log.Logger) (drivers.Driver, error) {
|
||||
return drivers.NewPylonDriver(c), nil
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -70,6 +70,10 @@ const (
|
||||
ConnectorProviderDeepgram ConnectorProvider = "DEEPGRAM"
|
||||
ConnectorProviderClickHouse ConnectorProvider = "CLICKHOUSE"
|
||||
ConnectorProviderLangfuse ConnectorProvider = "LANGFUSE"
|
||||
ConnectorProviderPylon ConnectorProvider = "PYLON"
|
||||
ConnectorProviderOpenRouter ConnectorProvider = "OPENROUTER"
|
||||
ConnectorProviderIncidentIO ConnectorProvider = "INCIDENT_IO"
|
||||
ConnectorProviderBrevo ConnectorProvider = "BREVO"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -127,6 +131,10 @@ func ConnectorProviders() []ConnectorProvider {
|
||||
ConnectorProviderDeepgram,
|
||||
ConnectorProviderClickHouse,
|
||||
ConnectorProviderLangfuse,
|
||||
ConnectorProviderPylon,
|
||||
ConnectorProviderOpenRouter,
|
||||
ConnectorProviderIncidentIO,
|
||||
ConnectorProviderBrevo,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,7 +187,11 @@ func (v ConnectorProvider) IsValid() bool {
|
||||
ConnectorProviderApollo,
|
||||
ConnectorProviderDeepgram,
|
||||
ConnectorProviderClickHouse,
|
||||
ConnectorProviderLangfuse:
|
||||
ConnectorProviderLangfuse,
|
||||
ConnectorProviderPylon,
|
||||
ConnectorProviderOpenRouter,
|
||||
ConnectorProviderIncidentIO,
|
||||
ConnectorProviderBrevo:
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
15
pkg/coredata/migrations/20260624T294736Z.sql
Normal file
15
pkg/coredata/migrations/20260624T294736Z.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 'BREVO';
|
||||
15
pkg/coredata/migrations/20260624T418293Z.sql
Normal file
15
pkg/coredata/migrations/20260624T418293Z.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 'PYLON';
|
||||
15
pkg/coredata/migrations/20260624T531847Z.sql
Normal file
15
pkg/coredata/migrations/20260624T531847Z.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 'OPENROUTER';
|
||||
15
pkg/coredata/migrations/20260624T672015Z.sql
Normal file
15
pkg/coredata/migrations/20260624T672015Z.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 'INCIDENT_IO';
|
||||
@@ -82,6 +82,16 @@ enum ConnectorProvider
|
||||
)
|
||||
LANGFUSE
|
||||
@goEnum(value: "go.probo.inc/probo/pkg/coredata.ConnectorProviderLangfuse")
|
||||
PYLON @goEnum(value: "go.probo.inc/probo/pkg/coredata.ConnectorProviderPylon")
|
||||
OPENROUTER
|
||||
@goEnum(
|
||||
value: "go.probo.inc/probo/pkg/coredata.ConnectorProviderOpenRouter"
|
||||
)
|
||||
INCIDENT_IO
|
||||
@goEnum(
|
||||
value: "go.probo.inc/probo/pkg/coredata.ConnectorProviderIncidentIO"
|
||||
)
|
||||
BREVO @goEnum(value: "go.probo.inc/probo/pkg/coredata.ConnectorProviderBrevo")
|
||||
}
|
||||
|
||||
type ConnectorProviderInfo {
|
||||
|
||||
Reference in New Issue
Block a user