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 {