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:
Aurélien Sibiril
2026-06-24 14:29:54 +02:00
parent f5f9842df5
commit a0d3806c21
34 changed files with 1922 additions and 15 deletions

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

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

View File

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

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

View 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}`)}))
}

View File

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

View 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)
}
}

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

View 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(""))
}

View 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{}
}
}

View 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"))
}

View 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")
}

View 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{}))
}

View 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

View 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

View 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

View 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

View File

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

View 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
},
}
}

View File

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

View 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
},
}
}

View 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
},
}
}

View File

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

View File

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

View 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
},
}
}

View File

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

View File

@@ -0,0 +1,15 @@
-- Copyright (c) 2026 Probo Inc <hello@probo.com>.
--
-- Permission to use, copy, modify, and/or distribute this software for any
-- purpose with or without fee is hereby granted, provided that the above
-- copyright notice and this permission notice appear in all copies.
--
-- THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
-- REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
-- AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
-- INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
-- LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
-- OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
-- PERFORMANCE OF THIS SOFTWARE.
ALTER TYPE connector_provider ADD VALUE IF NOT EXISTS 'BREVO';

View File

@@ -0,0 +1,15 @@
-- Copyright (c) 2026 Probo Inc <hello@probo.com>.
--
-- Permission to use, copy, modify, and/or distribute this software for any
-- purpose with or without fee is hereby granted, provided that the above
-- copyright notice and this permission notice appear in all copies.
--
-- THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
-- REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
-- AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
-- INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
-- LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
-- OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
-- PERFORMANCE OF THIS SOFTWARE.
ALTER TYPE connector_provider ADD VALUE IF NOT EXISTS 'PYLON';

View File

@@ -0,0 +1,15 @@
-- Copyright (c) 2026 Probo Inc <hello@probo.com>.
--
-- Permission to use, copy, modify, and/or distribute this software for any
-- purpose with or without fee is hereby granted, provided that the above
-- copyright notice and this permission notice appear in all copies.
--
-- THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
-- REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
-- AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
-- INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
-- LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
-- OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
-- PERFORMANCE OF THIS SOFTWARE.
ALTER TYPE connector_provider ADD VALUE IF NOT EXISTS 'OPENROUTER';

View File

@@ -0,0 +1,15 @@
-- Copyright (c) 2026 Probo Inc <hello@probo.com>.
--
-- Permission to use, copy, modify, and/or distribute this software for any
-- purpose with or without fee is hereby granted, provided that the above
-- copyright notice and this permission notice appear in all copies.
--
-- THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
-- REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
-- AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
-- INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
-- LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
-- OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
-- PERFORMANCE OF THIS SOFTWARE.
ALTER TYPE connector_provider ADD VALUE IF NOT EXISTS 'INCIDENT_IO';

View File

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