From a0d3806c21c34ebdc173881786c5b99754cd7804 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Aur=C3=A9lien=20Sibiril?=
<81782+aureliensibiril@users.noreply.github.com>
Date: Wed, 24 Jun 2026 14:29:54 +0200
Subject: [PATCH] Add four API-key access-review connectors
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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>
---
packages/ui/src/Atoms/ThirdParties/Brevo.tsx | 12 +
.../ui/src/Atoms/ThirdParties/IncidentIO.tsx | 17 ++
.../ui/src/Atoms/ThirdParties/OpenRouter.tsx | 12 +
packages/ui/src/Atoms/ThirdParties/Pylon.tsx | 23 ++
.../src/Atoms/ThirdParties/ThirdPartyLogo.tsx | 8 +
packages/ui/src/Atoms/ThirdParties/index.ts | 4 +
pkg/accessreview/drivers/brevo.go | 181 +++++++++++++
pkg/accessreview/drivers/brevo_test.go | 107 ++++++++
pkg/accessreview/drivers/driver.go | 23 ++
pkg/accessreview/drivers/driver_test.go | 45 ++++
pkg/accessreview/drivers/incidentio.go | 226 ++++++++++++++++
pkg/accessreview/drivers/incidentio_test.go | 108 ++++++++
pkg/accessreview/drivers/openrouter.go | 171 ++++++++++++
pkg/accessreview/drivers/openrouter_test.go | 74 +++++
pkg/accessreview/drivers/pylon.go | 254 ++++++++++++++++++
pkg/accessreview/drivers/pylon_test.go | 76 ++++++
pkg/accessreview/drivers/testdata/brevo.yaml | 35 +++
.../drivers/testdata/incidentio.yaml | 71 +++++
.../drivers/testdata/openrouter.yaml | 40 +++
pkg/accessreview/drivers/testdata/pylon.yaml | 64 +++++
pkg/accessreview/drivers/vcr_test.go | 9 +-
pkg/connector/provider/brevo.go | 49 ++++
pkg/connector/provider/builtin.go | 4 +
pkg/connector/provider/incidentio.go | 49 ++++
pkg/connector/provider/openrouter.go | 50 ++++
pkg/connector/provider/probe.go | 50 +++-
pkg/connector/provider/probe_test.go | 42 +++
pkg/connector/provider/pylon.go | 49 ++++
pkg/coredata/connector_provider.go | 14 +-
pkg/coredata/migrations/20260624T294736Z.sql | 15 ++
pkg/coredata/migrations/20260624T418293Z.sql | 15 ++
pkg/coredata/migrations/20260624T531847Z.sql | 15 ++
pkg/coredata/migrations/20260624T672015Z.sql | 15 ++
.../api/console/v1/graphql/connector.graphql | 10 +
34 files changed, 1922 insertions(+), 15 deletions(-)
create mode 100644 packages/ui/src/Atoms/ThirdParties/Brevo.tsx
create mode 100644 packages/ui/src/Atoms/ThirdParties/IncidentIO.tsx
create mode 100644 packages/ui/src/Atoms/ThirdParties/OpenRouter.tsx
create mode 100644 packages/ui/src/Atoms/ThirdParties/Pylon.tsx
create mode 100644 pkg/accessreview/drivers/brevo.go
create mode 100644 pkg/accessreview/drivers/brevo_test.go
create mode 100644 pkg/accessreview/drivers/driver_test.go
create mode 100644 pkg/accessreview/drivers/incidentio.go
create mode 100644 pkg/accessreview/drivers/incidentio_test.go
create mode 100644 pkg/accessreview/drivers/openrouter.go
create mode 100644 pkg/accessreview/drivers/openrouter_test.go
create mode 100644 pkg/accessreview/drivers/pylon.go
create mode 100644 pkg/accessreview/drivers/pylon_test.go
create mode 100644 pkg/accessreview/drivers/testdata/brevo.yaml
create mode 100644 pkg/accessreview/drivers/testdata/incidentio.yaml
create mode 100644 pkg/accessreview/drivers/testdata/openrouter.yaml
create mode 100644 pkg/accessreview/drivers/testdata/pylon.yaml
create mode 100644 pkg/connector/provider/brevo.go
create mode 100644 pkg/connector/provider/incidentio.go
create mode 100644 pkg/connector/provider/openrouter.go
create mode 100644 pkg/connector/provider/pylon.go
create mode 100644 pkg/coredata/migrations/20260624T294736Z.sql
create mode 100644 pkg/coredata/migrations/20260624T418293Z.sql
create mode 100644 pkg/coredata/migrations/20260624T531847Z.sql
create mode 100644 pkg/coredata/migrations/20260624T672015Z.sql
diff --git a/packages/ui/src/Atoms/ThirdParties/Brevo.tsx b/packages/ui/src/Atoms/ThirdParties/Brevo.tsx
new file mode 100644
index 000000000..c4f9c0da7
--- /dev/null
+++ b/packages/ui/src/Atoms/ThirdParties/Brevo.tsx
@@ -0,0 +1,12 @@
+import type { ComponentProps } from "react";
+
+export function Brevo(props: ComponentProps<"svg">) {
+ return (
+
+ );
+}
diff --git a/packages/ui/src/Atoms/ThirdParties/IncidentIO.tsx b/packages/ui/src/Atoms/ThirdParties/IncidentIO.tsx
new file mode 100644
index 000000000..cb3571949
--- /dev/null
+++ b/packages/ui/src/Atoms/ThirdParties/IncidentIO.tsx
@@ -0,0 +1,17 @@
+import type { ComponentProps } from "react";
+
+export function IncidentIO(props: ComponentProps<"svg">) {
+ return (
+
+ );
+}
diff --git a/packages/ui/src/Atoms/ThirdParties/OpenRouter.tsx b/packages/ui/src/Atoms/ThirdParties/OpenRouter.tsx
new file mode 100644
index 000000000..edc10b7f0
--- /dev/null
+++ b/packages/ui/src/Atoms/ThirdParties/OpenRouter.tsx
@@ -0,0 +1,12 @@
+import type { ComponentProps } from "react";
+
+export function OpenRouter(props: ComponentProps<"svg">) {
+ return (
+
+ );
+}
diff --git a/packages/ui/src/Atoms/ThirdParties/Pylon.tsx b/packages/ui/src/Atoms/ThirdParties/Pylon.tsx
new file mode 100644
index 000000000..665d9a089
--- /dev/null
+++ b/packages/ui/src/Atoms/ThirdParties/Pylon.tsx
@@ -0,0 +1,23 @@
+import type { ComponentProps } from "react";
+
+export function Pylon(props: ComponentProps<"svg">) {
+ return (
+
+ );
+}
diff --git a/packages/ui/src/Atoms/ThirdParties/ThirdPartyLogo.tsx b/packages/ui/src/Atoms/ThirdParties/ThirdPartyLogo.tsx
index a2318c2c0..4ebd696fe 100644
--- a/packages/ui/src/Atoms/ThirdParties/ThirdPartyLogo.tsx
+++ b/packages/ui/src/Atoms/ThirdParties/ThirdPartyLogo.tsx
@@ -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>> = {
ASANA: Asana,
BETTER_STACK: BetterStack,
BITBUCKET: Bitbucket,
+ BREVO: Brevo,
BREX: Brex,
CLERK: Clerk,
CLICKHOUSE: ClickHouse,
@@ -86,6 +91,7 @@ const thirdParties: Record>> = {
GRAFANA: Grafana,
HEROKU: Heroku,
HUBSPOT: HubSpot,
+ INCIDENT_IO: IncidentIO,
INTERCOM: Intercom,
LANGFUSE: Langfuse,
LINEAR: Linear,
@@ -101,8 +107,10 @@ const thirdParties: Record>> = {
ONE_PASSWORD: OnePassword,
ONEPASSWORD: OnePassword,
OPENAI: OpenAI,
+ OPENROUTER: OpenRouter,
PAGERDUTY: PagerDuty,
POSTHOG: PostHog,
+ PYLON: Pylon,
QOVERY: Qovery,
RENDER: Render,
RESEND: Resend,
diff --git a/packages/ui/src/Atoms/ThirdParties/index.ts b/packages/ui/src/Atoms/ThirdParties/index.ts
index 4b8c370df..c794a7a5f 100644
--- a/packages/ui/src/Atoms/ThirdParties/index.ts
+++ b/packages/ui/src/Atoms/ThirdParties/index.ts
@@ -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";
diff --git a/pkg/accessreview/drivers/brevo.go b/pkg/accessreview/drivers/brevo.go
new file mode 100644
index 000000000..2a3bb0ffd
--- /dev/null
+++ b/pkg/accessreview/drivers/brevo.go
@@ -0,0 +1,181 @@
+// Copyright (c) 2026 Probo Inc .
+//
+// Permission to use, copy, modify, and/or distribute this software for any
+// purpose with or without fee is hereby granted, provided that the above
+// copyright notice and this permission notice appear in all copies.
+//
+// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
+// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
+// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
+// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
+// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
+// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
+// PERFORMANCE OF THIS SOFTWARE.
+
+package drivers
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "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
+}
diff --git a/pkg/accessreview/drivers/brevo_test.go b/pkg/accessreview/drivers/brevo_test.go
new file mode 100644
index 000000000..62e8dd392
--- /dev/null
+++ b/pkg/accessreview/drivers/brevo_test.go
@@ -0,0 +1,107 @@
+// Copyright (c) 2026 Probo Inc .
+//
+// Permission to use, copy, modify, and/or distribute this software for any
+// purpose with or without fee is hereby granted, provided that the above
+// copyright notice and this permission notice appear in all copies.
+//
+// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
+// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
+// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
+// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
+// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
+// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
+// PERFORMANCE OF THIS SOFTWARE.
+
+package drivers
+
+import (
+ "context"
+ "encoding/json"
+ "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}`)}))
+}
diff --git a/pkg/accessreview/drivers/driver.go b/pkg/accessreview/drivers/driver.go
index 0911e45d2..134bd03fe 100644
--- a/pkg/accessreview/drivers/driver.go
+++ b/pkg/accessreview/drivers/driver.go
@@ -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
+ }
+}
diff --git a/pkg/accessreview/drivers/driver_test.go b/pkg/accessreview/drivers/driver_test.go
new file mode 100644
index 000000000..2c898061c
--- /dev/null
+++ b/pkg/accessreview/drivers/driver_test.go
@@ -0,0 +1,45 @@
+// Copyright (c) 2026 Probo Inc .
+//
+// Permission to use, copy, modify, and/or distribute this software for any
+// purpose with or without fee is hereby granted, provided that the above
+// copyright notice and this permission notice appear in all copies.
+//
+// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
+// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
+// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
+// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
+// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
+// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
+// PERFORMANCE OF THIS SOFTWARE.
+
+package drivers
+
+import (
+ "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)
+ }
+}
diff --git a/pkg/accessreview/drivers/incidentio.go b/pkg/accessreview/drivers/incidentio.go
new file mode 100644
index 000000000..8cdd4a970
--- /dev/null
+++ b/pkg/accessreview/drivers/incidentio.go
@@ -0,0 +1,226 @@
+// Copyright (c) 2026 Probo Inc .
+//
+// Permission to use, copy, modify, and/or distribute this software for any
+// purpose with or without fee is hereby granted, provided that the above
+// copyright notice and this permission notice appear in all copies.
+//
+// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
+// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
+// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
+// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
+// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
+// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
+// PERFORMANCE OF THIS SOFTWARE.
+
+package drivers
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "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
+ }
+}
diff --git a/pkg/accessreview/drivers/incidentio_test.go b/pkg/accessreview/drivers/incidentio_test.go
new file mode 100644
index 000000000..ee71aa2f5
--- /dev/null
+++ b/pkg/accessreview/drivers/incidentio_test.go
@@ -0,0 +1,108 @@
+// Copyright (c) 2026 Probo Inc .
+//
+// Permission to use, copy, modify, and/or distribute this software for any
+// purpose with or without fee is hereby granted, provided that the above
+// copyright notice and this permission notice appear in all copies.
+//
+// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
+// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
+// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
+// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
+// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
+// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
+// PERFORMANCE OF THIS SOFTWARE.
+
+package drivers
+
+import (
+ "context"
+ "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(""))
+}
diff --git a/pkg/accessreview/drivers/openrouter.go b/pkg/accessreview/drivers/openrouter.go
new file mode 100644
index 000000000..25c14b883
--- /dev/null
+++ b/pkg/accessreview/drivers/openrouter.go
@@ -0,0 +1,171 @@
+// Copyright (c) 2026 Probo Inc .
+//
+// Permission to use, copy, modify, and/or distribute this software for any
+// purpose with or without fee is hereby granted, provided that the above
+// copyright notice and this permission notice appear in all copies.
+//
+// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
+// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
+// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
+// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
+// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
+// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
+// PERFORMANCE OF THIS SOFTWARE.
+
+package drivers
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "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{}
+ }
+}
diff --git a/pkg/accessreview/drivers/openrouter_test.go b/pkg/accessreview/drivers/openrouter_test.go
new file mode 100644
index 000000000..6fbfb636d
--- /dev/null
+++ b/pkg/accessreview/drivers/openrouter_test.go
@@ -0,0 +1,74 @@
+// Copyright (c) 2026 Probo Inc .
+//
+// Permission to use, copy, modify, and/or distribute this software for any
+// purpose with or without fee is hereby granted, provided that the above
+// copyright notice and this permission notice appear in all copies.
+//
+// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
+// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
+// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
+// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
+// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
+// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
+// PERFORMANCE OF THIS SOFTWARE.
+
+package drivers
+
+import (
+ "context"
+ "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"))
+}
diff --git a/pkg/accessreview/drivers/pylon.go b/pkg/accessreview/drivers/pylon.go
new file mode 100644
index 000000000..023d3e379
--- /dev/null
+++ b/pkg/accessreview/drivers/pylon.go
@@ -0,0 +1,254 @@
+// Copyright (c) 2026 Probo Inc .
+//
+// Permission to use, copy, modify, and/or distribute this software for any
+// purpose with or without fee is hereby granted, provided that the above
+// copyright notice and this permission notice appear in all copies.
+//
+// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
+// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
+// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
+// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
+// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
+// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
+// PERFORMANCE OF THIS SOFTWARE.
+
+package drivers
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "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")
+}
diff --git a/pkg/accessreview/drivers/pylon_test.go b/pkg/accessreview/drivers/pylon_test.go
new file mode 100644
index 000000000..b9c74dac3
--- /dev/null
+++ b/pkg/accessreview/drivers/pylon_test.go
@@ -0,0 +1,76 @@
+// Copyright (c) 2026 Probo Inc .
+//
+// Permission to use, copy, modify, and/or distribute this software for any
+// purpose with or without fee is hereby granted, provided that the above
+// copyright notice and this permission notice appear in all copies.
+//
+// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
+// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
+// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
+// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
+// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
+// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
+// PERFORMANCE OF THIS SOFTWARE.
+
+package drivers
+
+import (
+ "context"
+ "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{}))
+}
diff --git a/pkg/accessreview/drivers/testdata/brevo.yaml b/pkg/accessreview/drivers/testdata/brevo.yaml
new file mode 100644
index 000000000..eb886c61a
--- /dev/null
+++ b/pkg/accessreview/drivers/testdata/brevo.yaml
@@ -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
diff --git a/pkg/accessreview/drivers/testdata/incidentio.yaml b/pkg/accessreview/drivers/testdata/incidentio.yaml
new file mode 100644
index 000000000..1308bfe38
--- /dev/null
+++ b/pkg/accessreview/drivers/testdata/incidentio.yaml
@@ -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
diff --git a/pkg/accessreview/drivers/testdata/openrouter.yaml b/pkg/accessreview/drivers/testdata/openrouter.yaml
new file mode 100644
index 000000000..953b0c065
--- /dev/null
+++ b/pkg/accessreview/drivers/testdata/openrouter.yaml
@@ -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
diff --git a/pkg/accessreview/drivers/testdata/pylon.yaml b/pkg/accessreview/drivers/testdata/pylon.yaml
new file mode 100644
index 000000000..d5c2e08b5
--- /dev/null
+++ b/pkg/accessreview/drivers/testdata/pylon.yaml
@@ -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
diff --git a/pkg/accessreview/drivers/vcr_test.go b/pkg/accessreview/drivers/vcr_test.go
index 24f64370e..e170ff6ce 100644
--- a/pkg/accessreview/drivers/vcr_test.go
+++ b/pkg/accessreview/drivers/vcr_test.go
@@ -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),
diff --git a/pkg/connector/provider/brevo.go b/pkg/connector/provider/brevo.go
new file mode 100644
index 000000000..9c6f113e2
--- /dev/null
+++ b/pkg/connector/provider/brevo.go
@@ -0,0 +1,49 @@
+// Copyright (c) 2026 Probo Inc .
+//
+// Permission to use, copy, modify, and/or distribute this software for any
+// purpose with or without fee is hereby granted, provided that the above
+// copyright notice and this permission notice appear in all copies.
+//
+// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
+// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
+// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
+// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
+// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
+// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
+// PERFORMANCE OF THIS SOFTWARE.
+
+package provider
+
+import (
+ "context"
+ "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
+ },
+ }
+}
diff --git a/pkg/connector/provider/builtin.go b/pkg/connector/provider/builtin.go
index bc140fff4..f9226b555 100644
--- a/pkg/connector/provider/builtin.go
+++ b/pkg/connector/provider/builtin.go
@@ -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(),
diff --git a/pkg/connector/provider/incidentio.go b/pkg/connector/provider/incidentio.go
new file mode 100644
index 000000000..8318f47eb
--- /dev/null
+++ b/pkg/connector/provider/incidentio.go
@@ -0,0 +1,49 @@
+// Copyright (c) 2026 Probo Inc .
+//
+// Permission to use, copy, modify, and/or distribute this software for any
+// purpose with or without fee is hereby granted, provided that the above
+// copyright notice and this permission notice appear in all copies.
+//
+// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
+// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
+// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
+// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
+// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
+// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
+// PERFORMANCE OF THIS SOFTWARE.
+
+package provider
+
+import (
+ "context"
+ "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
+ },
+ }
+}
diff --git a/pkg/connector/provider/openrouter.go b/pkg/connector/provider/openrouter.go
new file mode 100644
index 000000000..be7505a91
--- /dev/null
+++ b/pkg/connector/provider/openrouter.go
@@ -0,0 +1,50 @@
+// Copyright (c) 2026 Probo Inc .
+//
+// Permission to use, copy, modify, and/or distribute this software for any
+// purpose with or without fee is hereby granted, provided that the above
+// copyright notice and this permission notice appear in all copies.
+//
+// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
+// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
+// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
+// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
+// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
+// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
+// PERFORMANCE OF THIS SOFTWARE.
+
+package provider
+
+import (
+ "context"
+ "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
+ },
+ }
+}
diff --git a/pkg/connector/provider/probe.go b/pkg/connector/provider/probe.go
index e7c5ca63d..5334d0dbc 100644
--- a/pkg/connector/provider/probe.go
+++ b/pkg/connector/provider/probe.go
@@ -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,
diff --git a/pkg/connector/provider/probe_test.go b/pkg/connector/provider/probe_test.go
index d30981a06..f6c00e13b 100644
--- a/pkg/connector/provider/probe_test.go
+++ b/pkg/connector/provider/probe_test.go
@@ -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()
diff --git a/pkg/connector/provider/pylon.go b/pkg/connector/provider/pylon.go
new file mode 100644
index 000000000..4e8881620
--- /dev/null
+++ b/pkg/connector/provider/pylon.go
@@ -0,0 +1,49 @@
+// Copyright (c) 2026 Probo Inc .
+//
+// Permission to use, copy, modify, and/or distribute this software for any
+// purpose with or without fee is hereby granted, provided that the above
+// copyright notice and this permission notice appear in all copies.
+//
+// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
+// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
+// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
+// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
+// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
+// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
+// PERFORMANCE OF THIS SOFTWARE.
+
+package provider
+
+import (
+ "context"
+ "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
+ },
+ }
+}
diff --git a/pkg/coredata/connector_provider.go b/pkg/coredata/connector_provider.go
index 2d3fd561b..698e3e38c 100644
--- a/pkg/coredata/connector_provider.go
+++ b/pkg/coredata/connector_provider.go
@@ -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
}
diff --git a/pkg/coredata/migrations/20260624T294736Z.sql b/pkg/coredata/migrations/20260624T294736Z.sql
new file mode 100644
index 000000000..97d7dc0e4
--- /dev/null
+++ b/pkg/coredata/migrations/20260624T294736Z.sql
@@ -0,0 +1,15 @@
+-- Copyright (c) 2026 Probo Inc .
+--
+-- Permission to use, copy, modify, and/or distribute this software for any
+-- purpose with or without fee is hereby granted, provided that the above
+-- copyright notice and this permission notice appear in all copies.
+--
+-- THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
+-- REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
+-- AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
+-- INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
+-- LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
+-- OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
+-- PERFORMANCE OF THIS SOFTWARE.
+
+ALTER TYPE connector_provider ADD VALUE IF NOT EXISTS 'BREVO';
diff --git a/pkg/coredata/migrations/20260624T418293Z.sql b/pkg/coredata/migrations/20260624T418293Z.sql
new file mode 100644
index 000000000..55defe84d
--- /dev/null
+++ b/pkg/coredata/migrations/20260624T418293Z.sql
@@ -0,0 +1,15 @@
+-- Copyright (c) 2026 Probo Inc .
+--
+-- Permission to use, copy, modify, and/or distribute this software for any
+-- purpose with or without fee is hereby granted, provided that the above
+-- copyright notice and this permission notice appear in all copies.
+--
+-- THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
+-- REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
+-- AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
+-- INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
+-- LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
+-- OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
+-- PERFORMANCE OF THIS SOFTWARE.
+
+ALTER TYPE connector_provider ADD VALUE IF NOT EXISTS 'PYLON';
diff --git a/pkg/coredata/migrations/20260624T531847Z.sql b/pkg/coredata/migrations/20260624T531847Z.sql
new file mode 100644
index 000000000..3fa426cc8
--- /dev/null
+++ b/pkg/coredata/migrations/20260624T531847Z.sql
@@ -0,0 +1,15 @@
+-- Copyright (c) 2026 Probo Inc .
+--
+-- Permission to use, copy, modify, and/or distribute this software for any
+-- purpose with or without fee is hereby granted, provided that the above
+-- copyright notice and this permission notice appear in all copies.
+--
+-- THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
+-- REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
+-- AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
+-- INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
+-- LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
+-- OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
+-- PERFORMANCE OF THIS SOFTWARE.
+
+ALTER TYPE connector_provider ADD VALUE IF NOT EXISTS 'OPENROUTER';
diff --git a/pkg/coredata/migrations/20260624T672015Z.sql b/pkg/coredata/migrations/20260624T672015Z.sql
new file mode 100644
index 000000000..4212a4e0c
--- /dev/null
+++ b/pkg/coredata/migrations/20260624T672015Z.sql
@@ -0,0 +1,15 @@
+-- Copyright (c) 2026 Probo Inc .
+--
+-- Permission to use, copy, modify, and/or distribute this software for any
+-- purpose with or without fee is hereby granted, provided that the above
+-- copyright notice and this permission notice appear in all copies.
+--
+-- THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
+-- REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
+-- AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
+-- INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
+-- LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
+-- OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
+-- PERFORMANCE OF THIS SOFTWARE.
+
+ALTER TYPE connector_provider ADD VALUE IF NOT EXISTS 'INCIDENT_IO';
diff --git a/pkg/server/api/console/v1/graphql/connector.graphql b/pkg/server/api/console/v1/graphql/connector.graphql
index ac8adc3fc..dea99c01b 100644
--- a/pkg/server/api/console/v1/graphql/connector.graphql
+++ b/pkg/server/api/console/v1/graphql/connector.graphql
@@ -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 {