Add four API-key access-review connectors

Add Pylon, OpenRouter, incident.io and Brevo as access-review connectors.
All are API-key, single-tenant providers (Pattern 3): the key identifies
one tenant, so there is no OAuth flow, picker UI, or bootstrap/helm
configuration.

- Pylon: Bearer token, GET /users; resolves each user's opaque role_id to
  a role name via GET /user-roles, with cursor pagination.
- OpenRouter: Bearer management key, GET /api/v1/organization/members. The
  endpoint requires an organization account -- a personal key authenticates
  but returns 404 -- so the connection probe rejects 404 on top of 401/403
  (doProbeRequest gained an opt-in extra-reject set) to surface a non-org
  key at connect time instead of mid-campaign.
- incident.io: Bearer token, GET /v2/users. Its OAuth is outbound-only, so
  the API key is the inbound path; live base_role/custom_roles take
  precedence over the deprecated role enum.
- Brevo: API key in the api-key header (Registration.APIKeyHeader), GET
  /v3/organization/invited/users. A live recording corrected the documented
  schema: is_owner is a JSON boolean (not a string) and an id field is
  present, so it is used as the stable ExternalID.

The OpenRouter and Brevo cassettes are anonymized live recordings; Pylon
and incident.io use hand-authored fixtures (no self-serve test tenant). The
shared three-valued active-status mapping is consolidated into
activeFromStatus in driver.go.

Each adds the enum value, migration, GraphQL binding, provider
Registration, a driver with a cassette-driven test, and a brand logo.

Signed-off-by: Aurélien Sibiril <81782+aureliensibiril@users.noreply.github.com>
This commit is contained in:
Aurélien Sibiril
2026-06-24 14:29:54 +02:00
parent f5f9842df5
commit a0d3806c21
34 changed files with 1922 additions and 15 deletions

View File

@@ -0,0 +1,181 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package drivers
import (
"context"
"encoding/json"
"fmt"
"net/http"
"sort"
"strings"
"go.probo.inc/probo/pkg/coredata"
)
const brevoInvitedUsersEndpoint = "https://api.brevo.com/v3/organization/invited/users"
// BrevoDriver lists the invited users (organization seats) of a single Brevo
// account. The API key (sent in the api-key header by the connection
// transport) is bound to one account, so GET /v3/organization/invited/users
// returns every invited user of that account with no tenant selector and no
// pagination.
type BrevoDriver struct {
httpClient *http.Client
}
var _ Driver = (*BrevoDriver)(nil)
type brevoInvitedUser struct {
// ID is Brevo's stable user identifier (a Mongo-style ObjectID). The
// documented schema omits it, but the live API returns it, so it is
// preferred over the email as the ExternalID.
ID string `json:"id"`
Email string `json:"email"`
// IsOwner flags the account owner. The live API returns a JSON boolean
// while older docs/SDK show a string ("true"/"false"); decoded as
// RawMessage and read via brevoIsOwner to tolerate both shapes.
IsOwner json.RawMessage `json:"is_owner"`
// Status is the invitation state: "active" or "pending".
Status string `json:"status"`
// FeatureAccess maps a feature area (marketing / crm / conversations /
// transactional / phone / …) to the user's access level on it. Values are
// strings (e.g. "owner", "full", "none"); decoded as RawMessage so a
// non-string shape degrades gracefully instead of failing the whole
// decode.
FeatureAccess map[string]json.RawMessage `json:"feature_access"`
}
type brevoInvitedUsersResponse struct {
Users []brevoInvitedUser `json:"users"`
}
func NewBrevoDriver(httpClient *http.Client) *BrevoDriver {
return &BrevoDriver{httpClient: httpClient}
}
func (d *BrevoDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, brevoInvitedUsersEndpoint, nil)
if err != nil {
return nil, fmt.Errorf("cannot create brevo invited users request: %w", err)
}
req.Header.Set("Accept", "application/json")
httpResp, err := d.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("cannot execute brevo invited users request: %w", err)
}
defer func() {
_ = httpResp.Body.Close()
}()
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
return nil, fmt.Errorf("cannot fetch brevo invited users: unexpected status %d", httpResp.StatusCode)
}
var resp brevoInvitedUsersResponse
if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil {
return nil, fmt.Errorf("cannot decode brevo invited users response: %w", err)
}
records := make([]AccountRecord, 0, len(resp.Users))
for _, u := range resp.Users {
email := strings.TrimSpace(u.Email)
if email == "" {
continue
}
records = append(records, AccountRecord{
Email: email,
// Brevo's invited-users API exposes no display name.
FullName: email,
Roles: brevoRoles(u.FeatureAccess),
Active: activeFromStatus(u.Status),
IsAdmin: brevoIsOwner(u.IsOwner),
MFAStatus: coredata.MFAStatusUnknown,
AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown,
AccountType: coredata.AccessReviewEntryAccountTypeUser,
ExternalID: brevoExternalID(u, email),
})
}
return records, nil
}
// brevoExternalID prefers Brevo's stable user id, falling back to the email
// (the only other durable identifier) when an account has none.
func brevoExternalID(u brevoInvitedUser, email string) string {
if id := strings.TrimSpace(u.ID); id != "" {
return id
}
return email
}
// brevoIsOwner reads the account-owner flag, tolerating both the JSON boolean
// the live API returns and the string ("true"/"false") shown in older
// docs/SDK.
func brevoIsOwner(raw json.RawMessage) bool {
if len(raw) == 0 {
return false
}
var b bool
if err := json.Unmarshal(raw, &b); err == nil {
return b
}
var s string
if err := json.Unmarshal(raw, &s); err == nil {
return strings.EqualFold(strings.TrimSpace(s), "true")
}
return false
}
// brevoRoles summarises an invited user's per-feature access levels
// (marketing / crm / conversations) into a de-duplicated, sorted set of role
// labels. Each feature_access value is normally a string such as "owner";
// the "none" level and any non-string shape are skipped so the result holds
// only the access levels actually granted.
func brevoRoles(featureAccess map[string]json.RawMessage) []string {
seen := make(map[string]struct{})
for _, raw := range featureAccess {
var level string
if err := json.Unmarshal(raw, &level); err != nil {
continue
}
level = strings.TrimSpace(level)
if level == "" || strings.EqualFold(level, "none") {
continue
}
seen[level] = struct{}{}
}
roles := make([]string, 0, len(seen))
for level := range seen {
roles = append(roles, level)
}
sort.Strings(roles)
return roles
}

View File

@@ -0,0 +1,107 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package drivers
import (
"context"
"encoding/json"
"os"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.probo.inc/probo/pkg/coredata"
)
func TestBrevoDriver(t *testing.T) {
t.Parallel()
rec := newRecorder(t, "testdata/brevo", "BREVO_API_KEY")
// Brevo authenticates via the api-key header, not Authorization.
client := newVCRClientWithHeader(rec, "api-key", os.Getenv("BREVO_API_KEY"))
driver := NewBrevoDriver(client)
records, err := driver.ListAccounts(context.Background())
require.NoError(t, err)
require.Len(t, records, 3)
// Cassette recorded live (api-key header), then anonymized: the owner has
// every feature at "owner"; the two members have crm/transactional "full"
// and the rest "none".
owner := records[0]
assert.Equal(t, "000000000000000000000001", owner.ExternalID)
assert.Equal(t, "owner@example.com", owner.Email)
assert.Equal(t, "owner@example.com", owner.FullName)
assert.True(t, owner.IsAdmin)
require.NotNil(t, owner.Active)
assert.True(t, *owner.Active)
assert.Equal(t, []string{"owner"}, owner.Roles)
assert.Equal(t, coredata.AccessReviewEntryAccountTypeUser, owner.AccountType)
// Non-owner; "none" levels filtered, the remaining "full" de-duplicated.
member := records[1]
assert.Equal(t, "000000000000000000000002", member.ExternalID)
assert.False(t, member.IsAdmin)
assert.Equal(t, []string{"full"}, member.Roles)
require.NotNil(t, member.Active)
assert.True(t, *member.Active)
// The third record is asserted too, so a swap or corruption is caught.
viewer := records[2]
assert.Equal(t, "000000000000000000000003", viewer.ExternalID)
assert.Equal(t, "viewer@example.com", viewer.Email)
assert.False(t, viewer.IsAdmin)
assert.Equal(t, []string{"full"}, viewer.Roles)
}
func TestBrevoExternalID(t *testing.T) {
t.Parallel()
// The stable id is preferred when present.
assert.Equal(t, "abc123", brevoExternalID(brevoInvitedUser{ID: "abc123"}, "x@example.com"))
// With no id, the email is the fallback.
assert.Equal(t, "x@example.com", brevoExternalID(brevoInvitedUser{}, "x@example.com"))
assert.Equal(t, "x@example.com", brevoExternalID(brevoInvitedUser{ID: " "}, "x@example.com"))
}
func TestBrevoIsOwner(t *testing.T) {
t.Parallel()
// The live API returns a JSON boolean; older docs/SDK show a string.
// Both must be tolerated.
assert.True(t, brevoIsOwner(json.RawMessage(`true`)))
assert.False(t, brevoIsOwner(json.RawMessage(`false`)))
assert.True(t, brevoIsOwner(json.RawMessage(`"true"`)))
assert.False(t, brevoIsOwner(json.RawMessage(`"false"`)))
assert.False(t, brevoIsOwner(nil))
}
func TestBrevoRoles(t *testing.T) {
t.Parallel()
// Distinct non-"none" levels, sorted; "none" is filtered out.
roles := brevoRoles(map[string]json.RawMessage{
"marketing": json.RawMessage(`"owner"`),
"conversations": json.RawMessage(`"owner"`),
"crm": json.RawMessage(`"none"`),
})
assert.Equal(t, []string{"owner"}, roles)
// All "none" → no roles.
assert.Empty(t, brevoRoles(map[string]json.RawMessage{"crm": json.RawMessage(`"none"`)}))
// A non-string shape is ignored rather than failing.
assert.Empty(t, brevoRoles(map[string]json.RawMessage{"crm": json.RawMessage(`{"x":1}`)}))
}

View File

@@ -17,6 +17,7 @@ package drivers
import (
"context"
"fmt"
"strings"
"time"
"go.probo.inc/probo/pkg/coredata"
@@ -83,3 +84,25 @@ func parseRFC3339Ptr(s string) *time.Time {
return &t
}
// activeFromStatus maps a provider status string to the three-valued Active
// signal for providers whose only "live" state is the literal "active" and
// whose remaining status enum is not otherwise enumerated: "active" → active,
// an empty status → nil (no signal), and any other non-empty status →
// inactive. Used by drivers like Pylon and Brevo; a provider with a fully
// known status enum (e.g. Render's active/inactive) maps its own values
// explicitly instead, so an unrecognised value stays nil rather than false.
func activeFromStatus(status string) *bool {
switch strings.ToLower(strings.TrimSpace(status)) {
case "active":
active := true
return &active
case "":
return nil
default:
inactive := false
return &inactive
}
}

View File

@@ -0,0 +1,45 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package drivers
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestActiveFromStatus(t *testing.T) {
t.Parallel()
// "active" → active, case-insensitive.
for _, s := range []string{"active", "ACTIVE", " Active "} {
got := activeFromStatus(s)
require.NotNilf(t, got, "status %q", s)
assert.Truef(t, *got, "status %q", s)
}
// Empty/whitespace status → no signal (nil).
assert.Nil(t, activeFromStatus(""))
assert.Nil(t, activeFromStatus(" "))
// Any other non-empty status is treated as inactive (Brevo "pending",
// Pylon "deactivated", and any unrecognised future value).
for _, s := range []string{"pending", "deactivated", "disabled", "whatever"} {
got := activeFromStatus(s)
require.NotNilf(t, got, "status %q", s)
assert.Falsef(t, *got, "status %q", s)
}
}

View File

@@ -0,0 +1,226 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package drivers
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
"go.probo.inc/probo/pkg/coredata"
)
const (
incidentIOUsersEndpoint = "https://api.incident.io/v2/users"
// incidentIOPageSize is the page size requested from GET /v2/users (the
// API defaults to 25 and accepts up to 10000).
incidentIOPageSize = 100
)
// IncidentIODriver lists the users of a single incident.io organization. The
// API key (Bearer) is bound to one organization, so GET /v2/users returns
// every user of that organization with no tenant selector.
type IncidentIODriver struct {
httpClient *http.Client
}
var _ Driver = (*IncidentIODriver)(nil)
type incidentIORole struct {
Name string `json:"name"`
Slug string `json:"slug"`
}
type incidentIOUser struct {
ID string `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
// Role is the deprecated coarse role enum: owner / administrator /
// responder / viewer / unset. base_role / custom_roles are the live
// RBAC roles and take precedence when present.
Role string `json:"role"`
BaseRole *incidentIORole `json:"base_role"`
CustomRoles []incidentIORole `json:"custom_roles"`
}
type incidentIOUsersResponse struct {
Users []incidentIOUser `json:"users"`
PaginationMeta struct {
After string `json:"after"`
} `json:"pagination_meta"`
}
func NewIncidentIODriver(httpClient *http.Client) *IncidentIODriver {
return &IncidentIODriver{httpClient: httpClient}
}
func (d *IncidentIODriver) ListAccounts(ctx context.Context) ([]AccountRecord, error) {
var records []AccountRecord
after := ""
for range maxPaginationPages {
resp, err := d.fetchUsersPage(ctx, after)
if err != nil {
return nil, err
}
for _, u := range resp.Users {
email := strings.TrimSpace(u.Email)
if email == "" {
continue
}
records = append(records, AccountRecord{
Email: email,
FullName: incidentIOFullName(u, email),
Roles: incidentIORoles(u),
IsAdmin: incidentIOIsAdmin(u),
MFAStatus: coredata.MFAStatusUnknown,
AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown,
AccountType: coredata.AccessReviewEntryAccountTypeUser,
ExternalID: strings.TrimSpace(u.ID),
})
}
// The `after` cursor is the authoritative end-of-results signal: stop
// when it is empty. A short page is NOT treated as the end (incident.io
// may return fewer than page_size rows while more pages remain). The
// empty-page guard is only a backstop against an API that never clears
// the cursor, so the loop cannot spin past the data.
if resp.PaginationMeta.After == "" || len(resp.Users) == 0 {
return records, nil
}
after = resp.PaginationMeta.After
}
return nil, fmt.Errorf("cannot list all incident.io users: %w", ErrPaginationLimitReached)
}
func (d *IncidentIODriver) fetchUsersPage(ctx context.Context, after string) (*incidentIOUsersResponse, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, incidentIOUsersEndpoint, nil)
if err != nil {
return nil, fmt.Errorf("cannot create incident.io users request: %w", err)
}
q := req.URL.Query()
q.Set("page_size", strconv.Itoa(incidentIOPageSize))
if after != "" {
q.Set("after", after)
}
req.URL.RawQuery = q.Encode()
req.Header.Set("Accept", "application/json")
httpResp, err := d.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("cannot execute incident.io users request: %w", err)
}
defer func() {
_ = httpResp.Body.Close()
}()
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
return nil, fmt.Errorf("cannot fetch incident.io users: unexpected status %d", httpResp.StatusCode)
}
var resp incidentIOUsersResponse
if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil {
return nil, fmt.Errorf("cannot decode incident.io users response: %w", err)
}
return &resp, nil
}
func incidentIOFullName(u incidentIOUser, fallback string) string {
if name := strings.TrimSpace(u.Name); name != "" {
return name
}
return fallback
}
// incidentIORoles returns the user's roles, preferring the live RBAC roles
// (base_role + custom_roles) and falling back to the deprecated `role` enum
// only when no RBAC role is present.
func incidentIORoles(u incidentIOUser) []string {
roles := []string{}
if u.BaseRole != nil {
if name := strings.TrimSpace(u.BaseRole.Name); name != "" {
roles = append(roles, name)
}
}
for _, r := range u.CustomRoles {
if name := strings.TrimSpace(r.Name); name != "" {
roles = append(roles, name)
}
}
if len(roles) > 0 {
return roles
}
if name := incidentIODeprecatedRoleName(u.Role); name != "" {
return []string{name}
}
return []string{}
}
// incidentIODeprecatedRoleName maps the deprecated coarse role enum to a
// display label, returning "" for "unset" or an absent value.
func incidentIODeprecatedRoleName(role string) string {
switch strings.ToLower(strings.TrimSpace(role)) {
case "owner":
return "Owner"
case "administrator":
return "Administrator"
case "responder":
return "Responder"
case "viewer":
return "Viewer"
default:
return ""
}
}
// incidentIOIsAdmin reports whether the user holds an administrative role. It
// prefers the live base_role slug and falls back to the deprecated role enum;
// both "owner" and "administrator" are administrative.
func incidentIOIsAdmin(u incidentIOUser) bool {
if u.BaseRole != nil && strings.TrimSpace(u.BaseRole.Slug) != "" {
return incidentIOAdminSlug(u.BaseRole.Slug)
}
return incidentIOAdminSlug(u.Role)
}
func incidentIOAdminSlug(slug string) bool {
switch strings.ToLower(strings.TrimSpace(slug)) {
case "owner", "administrator":
return true
default:
return false
}
}

View File

@@ -0,0 +1,108 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package drivers
import (
"context"
"os"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.probo.inc/probo/pkg/coredata"
)
func TestIncidentIODriver(t *testing.T) {
t.Parallel()
rec := newRecorder(t, "testdata/incidentio", "INCIDENT_IO_API_KEY")
client := newVCRClient(rec, bearerAuth(os.Getenv("INCIDENT_IO_API_KEY")))
driver := NewIncidentIODriver(client)
records, err := driver.ListAccounts(context.Background())
require.NoError(t, err)
// Three records spread across two pages: the cassette's first page is
// short (2 < page_size) but carries a non-empty `after`, so getting all
// three proves the driver follows the cursor instead of stopping early.
require.Len(t, records, 3)
owner := records[0]
assert.Equal(t, "01ABCOWNER", owner.ExternalID)
assert.Equal(t, "lisa@example.com", owner.Email)
assert.Equal(t, "Lisa Curtis", owner.FullName)
assert.Equal(t, []string{"Owner"}, owner.Roles)
assert.True(t, owner.IsAdmin)
assert.Equal(t, coredata.AccessReviewEntryAccountTypeUser, owner.AccountType)
// /v2/users carries no account-status field, so Active stays nil.
assert.Nil(t, owner.Active)
// Live base_role + custom_roles are unioned; a responder is not an admin.
responder := records[1]
assert.Equal(t, []string{"Responder", "On-call Lead"}, responder.Roles)
assert.False(t, responder.IsAdmin)
// base_role null → falls back to the deprecated role enum; empty name →
// the display name falls back to the email.
legacy := records[2]
assert.Equal(t, "legacy-admin@example.com", legacy.FullName)
assert.Equal(t, []string{"Administrator"}, legacy.Roles)
assert.True(t, legacy.IsAdmin)
}
func TestIncidentIORoles(t *testing.T) {
t.Parallel()
// base_role + custom_roles are unioned, base first.
full := incidentIOUser{
BaseRole: &incidentIORole{Name: "Owner", Slug: "owner"},
CustomRoles: []incidentIORole{{Name: "On-call Lead", Slug: "on-call-lead"}},
Role: "viewer",
}
assert.Equal(t, []string{"Owner", "On-call Lead"}, incidentIORoles(full))
// No live RBAC role → falls back to the deprecated enum.
assert.Equal(t, []string{"Responder"}, incidentIORoles(incidentIOUser{Role: "responder"}))
// No role at all → empty slice.
assert.Equal(t, []string{}, incidentIORoles(incidentIOUser{Role: "unset"}))
}
func TestIncidentIOIsAdmin(t *testing.T) {
t.Parallel()
// The live base_role slug wins, including the "administrator" slug that
// the cassette does not exercise.
assert.True(t, incidentIOIsAdmin(incidentIOUser{BaseRole: &incidentIORole{Slug: "owner"}}))
assert.True(t, incidentIOIsAdmin(incidentIOUser{BaseRole: &incidentIORole{Slug: "administrator"}}))
assert.False(t, incidentIOIsAdmin(incidentIOUser{BaseRole: &incidentIORole{Slug: "responder"}}))
// A non-admin base_role is NOT overridden by an admin deprecated role.
assert.False(t, incidentIOIsAdmin(incidentIOUser{BaseRole: &incidentIORole{Slug: "viewer"}, Role: "administrator"}))
// No base_role → falls back to the deprecated role enum.
assert.True(t, incidentIOIsAdmin(incidentIOUser{Role: "administrator"}))
assert.True(t, incidentIOIsAdmin(incidentIOUser{Role: "owner"}))
assert.False(t, incidentIOIsAdmin(incidentIOUser{Role: "viewer"}))
}
func TestIncidentIODeprecatedRoleName(t *testing.T) {
t.Parallel()
assert.Equal(t, "Owner", incidentIODeprecatedRoleName("owner"))
assert.Equal(t, "Administrator", incidentIODeprecatedRoleName("administrator"))
assert.Equal(t, "Responder", incidentIODeprecatedRoleName("responder"))
assert.Equal(t, "Viewer", incidentIODeprecatedRoleName("viewer"))
// "unset" and an absent value map to no role.
assert.Equal(t, "", incidentIODeprecatedRoleName("unset"))
assert.Equal(t, "", incidentIODeprecatedRoleName(""))
}

View File

@@ -0,0 +1,171 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package drivers
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
"go.probo.inc/probo/pkg/coredata"
)
const (
openRouterMembersEndpoint = "https://openrouter.ai/api/v1/organization/members"
// openRouterPageSize is the maximum page size GET /organization/members
// accepts (limit must be between 1 and 100).
openRouterPageSize = 100
)
// OpenRouterDriver lists the members of a single OpenRouter organization. The
// management (provisioning) API key is bound to one organization, so GET
// /api/v1/organization/members returns every member of that organization
// with no tenant selector.
type OpenRouterDriver struct {
httpClient *http.Client
}
var _ Driver = (*OpenRouterDriver)(nil)
type openRouterMember struct {
ID string `json:"id"`
Email string `json:"email"`
FirstName *string `json:"first_name"`
LastName *string `json:"last_name"`
// Role is OpenRouter's organization role enum: "org:admin" or
// "org:member".
Role string `json:"role"`
}
type openRouterMembersResponse struct {
Data []openRouterMember `json:"data"`
TotalCount int `json:"total_count"`
}
func NewOpenRouterDriver(httpClient *http.Client) *OpenRouterDriver {
return &OpenRouterDriver{httpClient: httpClient}
}
func (d *OpenRouterDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error) {
var records []AccountRecord
offset := 0
for range maxPaginationPages {
resp, err := d.fetchMembersPage(ctx, offset)
if err != nil {
return nil, err
}
for _, m := range resp.Data {
email := strings.TrimSpace(m.Email)
if email == "" {
continue
}
records = append(records, AccountRecord{
Email: email,
FullName: openRouterFullName(m, email),
Roles: openRouterRoles(m.Role),
IsAdmin: m.Role == "org:admin",
MFAStatus: coredata.MFAStatusUnknown,
AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown,
AccountType: coredata.AccessReviewEntryAccountTypeUser,
ExternalID: strings.TrimSpace(m.ID),
})
}
offset += len(resp.Data)
if len(resp.Data) < openRouterPageSize || offset >= resp.TotalCount {
return records, nil
}
}
return nil, fmt.Errorf("cannot list all openrouter members: %w", ErrPaginationLimitReached)
}
func (d *OpenRouterDriver) fetchMembersPage(ctx context.Context, offset int) (*openRouterMembersResponse, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, openRouterMembersEndpoint, nil)
if err != nil {
return nil, fmt.Errorf("cannot create openrouter members request: %w", err)
}
q := req.URL.Query()
q.Set("limit", strconv.Itoa(openRouterPageSize))
q.Set("offset", strconv.Itoa(offset))
req.URL.RawQuery = q.Encode()
req.Header.Set("Accept", "application/json")
httpResp, err := d.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("cannot execute openrouter members request: %w", err)
}
defer func() {
_ = httpResp.Body.Close()
}()
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
return nil, fmt.Errorf("cannot fetch openrouter members: unexpected status %d", httpResp.StatusCode)
}
var resp openRouterMembersResponse
if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil {
return nil, fmt.Errorf("cannot decode openrouter members response: %w", err)
}
return &resp, nil
}
func openRouterFullName(m openRouterMember, fallback string) string {
first := ""
if m.FirstName != nil {
first = strings.TrimSpace(*m.FirstName)
}
last := ""
if m.LastName != nil {
last = strings.TrimSpace(*m.LastName)
}
full := strings.TrimSpace(first + " " + last)
if full != "" {
return full
}
return fallback
}
// openRouterRoles maps OpenRouter's organization role enum
// (org:admin / org:member) to a display label, preserving any unknown
// future role verbatim.
func openRouterRoles(role string) []string {
switch role {
case "org:admin":
return []string{"Admin"}
case "org:member":
return []string{"Member"}
default:
if strings.TrimSpace(role) != "" {
return []string{role}
}
return []string{}
}
}

View File

@@ -0,0 +1,74 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package drivers
import (
"context"
"os"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.probo.inc/probo/pkg/coredata"
)
func TestOpenRouterDriver(t *testing.T) {
t.Parallel()
rec := newRecorder(t, "testdata/openrouter", "OPENROUTER_API_KEY")
client := newVCRClient(rec, bearerAuth(os.Getenv("OPENROUTER_API_KEY")))
driver := NewOpenRouterDriver(client)
records, err := driver.ListAccounts(context.Background())
require.NoError(t, err)
require.Len(t, records, 1)
// Cassette recorded live against an OpenRouter organization (single admin
// member), then anonymized.
admin := records[0]
assert.Equal(t, "user_000000000000000000000admin", admin.ExternalID)
assert.Equal(t, "ada.admin@example.com", admin.Email)
assert.Equal(t, "Ada Admin", admin.FullName)
assert.Equal(t, []string{"Admin"}, admin.Roles)
assert.True(t, admin.IsAdmin)
assert.Equal(t, coredata.AccessReviewEntryAccountTypeUser, admin.AccountType)
// The members endpoint carries no account-status field, so Active stays nil.
assert.Nil(t, admin.Active)
}
func TestOpenRouterRoles(t *testing.T) {
t.Parallel()
assert.Equal(t, []string{"Admin"}, openRouterRoles("org:admin"))
assert.Equal(t, []string{"Member"}, openRouterRoles("org:member"))
// An unknown future role is preserved verbatim; an empty role yields none.
assert.Equal(t, []string{"org:billing"}, openRouterRoles("org:billing"))
assert.Equal(t, []string{}, openRouterRoles(""))
}
func TestOpenRouterFullName(t *testing.T) {
t.Parallel()
first, last := "Bob", "Member"
// first + last.
assert.Equal(t, "Bob Member", openRouterFullName(openRouterMember{FirstName: &first, LastName: &last}, "bob@example.com"))
// last_name null → first name alone.
assert.Equal(t, "Bob", openRouterFullName(openRouterMember{FirstName: &first}, "bob@example.com"))
// first_name null → last name alone.
assert.Equal(t, "Member", openRouterFullName(openRouterMember{LastName: &last}, "bob@example.com"))
// both null → email fallback.
assert.Equal(t, "carol@example.com", openRouterFullName(openRouterMember{}, "carol@example.com"))
}

View File

@@ -0,0 +1,254 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package drivers
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
"go.probo.inc/probo/pkg/coredata"
)
const (
pylonUsersEndpoint = "https://api.usepylon.com/users"
pylonUserRolesEndpoint = "https://api.usepylon.com/user-roles"
// pylonPageSize is the page size requested from the cursor-paginated
// list endpoints. Pylon caps `limit` at 999 (it must be > 0 and < 1000);
// 100 returns every member of typical organizations in one page.
pylonPageSize = 100
)
// PylonDriver lists the users (agents) of a single Pylon organization. The
// API token (Bearer) is bound to one organization, so GET /users returns
// every member of that organization with no tenant selector. Each user
// carries an opaque role_id, which the driver resolves to a human-readable
// role name via GET /user-roles.
type PylonDriver struct {
httpClient *http.Client
}
var _ Driver = (*PylonDriver)(nil)
type pylonUser struct {
ID string `json:"id"`
Email string `json:"email"`
Name string `json:"name"`
RoleID string `json:"role_id"`
Status string `json:"status"`
}
type pylonRole struct {
ID string `json:"id"`
Name string `json:"name"`
Slug string `json:"slug"`
}
type pylonPagination struct {
Cursor string `json:"cursor"`
HasNextPage bool `json:"has_next_page"`
}
type pylonUsersResponse struct {
Data []pylonUser `json:"data"`
Pagination pylonPagination `json:"pagination"`
}
type pylonRolesResponse struct {
Data []pylonRole `json:"data"`
Pagination pylonPagination `json:"pagination"`
}
func NewPylonDriver(httpClient *http.Client) *PylonDriver {
return &PylonDriver{httpClient: httpClient}
}
func (d *PylonDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error) {
roles, err := d.fetchRoles(ctx)
if err != nil {
return nil, err
}
var (
records []AccountRecord
cursor string
)
for range maxPaginationPages {
resp, err := d.fetchUsersPage(ctx, cursor)
if err != nil {
return nil, err
}
for _, u := range resp.Data {
email := strings.TrimSpace(u.Email)
if email == "" {
continue
}
role := roles[u.RoleID]
records = append(records, AccountRecord{
Email: email,
FullName: pylonFullName(u, email),
Roles: pylonRoles(role),
Active: activeFromStatus(u.Status),
IsAdmin: pylonIsAdmin(role),
MFAStatus: coredata.MFAStatusUnknown,
AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown,
AccountType: coredata.AccessReviewEntryAccountTypeUser,
ExternalID: strings.TrimSpace(u.ID),
})
}
if !resp.Pagination.HasNextPage || resp.Pagination.Cursor == "" {
return records, nil
}
cursor = resp.Pagination.Cursor
}
return nil, fmt.Errorf("cannot list all pylon users: %w", ErrPaginationLimitReached)
}
// fetchRoles loads the organization's role catalogue once, keyed by role ID,
// so each user's opaque role_id can be resolved to a role name and admin
// classification.
func (d *PylonDriver) fetchRoles(ctx context.Context) (map[string]pylonRole, error) {
roles := make(map[string]pylonRole)
cursor := ""
for range maxPaginationPages {
resp, err := d.fetchRolesPage(ctx, cursor)
if err != nil {
return nil, err
}
for _, r := range resp.Data {
roles[r.ID] = r
}
if !resp.Pagination.HasNextPage || resp.Pagination.Cursor == "" {
return roles, nil
}
cursor = resp.Pagination.Cursor
}
return nil, fmt.Errorf("cannot list all pylon user-roles: %w", ErrPaginationLimitReached)
}
func (d *PylonDriver) fetchUsersPage(ctx context.Context, cursor string) (*pylonUsersResponse, error) {
httpResp, err := d.fetchPage(ctx, pylonUsersEndpoint, cursor)
if err != nil {
return nil, err
}
defer func() {
_ = httpResp.Body.Close()
}()
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
return nil, fmt.Errorf("cannot fetch pylon users: unexpected status %d", httpResp.StatusCode)
}
var resp pylonUsersResponse
if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil {
return nil, fmt.Errorf("cannot decode pylon users response: %w", err)
}
return &resp, nil
}
func (d *PylonDriver) fetchRolesPage(ctx context.Context, cursor string) (*pylonRolesResponse, error) {
httpResp, err := d.fetchPage(ctx, pylonUserRolesEndpoint, cursor)
if err != nil {
return nil, err
}
defer func() {
_ = httpResp.Body.Close()
}()
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
return nil, fmt.Errorf("cannot fetch pylon user-roles: unexpected status %d", httpResp.StatusCode)
}
var resp pylonRolesResponse
if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil {
return nil, fmt.Errorf("cannot decode pylon user-roles response: %w", err)
}
return &resp, nil
}
func (d *PylonDriver) fetchPage(ctx context.Context, endpoint, cursor string) (*http.Response, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, fmt.Errorf("cannot create pylon request: %w", err)
}
q := req.URL.Query()
q.Set("limit", strconv.Itoa(pylonPageSize))
if cursor != "" {
q.Set("cursor", cursor)
}
req.URL.RawQuery = q.Encode()
req.Header.Set("Accept", "application/json")
httpResp, err := d.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("cannot execute pylon request: %w", err)
}
return httpResp, nil
}
func pylonFullName(u pylonUser, fallback string) string {
if name := strings.TrimSpace(u.Name); name != "" {
return name
}
return fallback
}
// pylonRoles returns the user's role as a single-element slice using the
// resolved role name, or an empty slice when the role_id did not resolve.
func pylonRoles(role pylonRole) []string {
if name := strings.TrimSpace(role.Name); name != "" {
return []string{name}
}
return []string{}
}
// pylonIsAdmin reports whether the resolved role is Pylon's built-in Admin
// role. Pylon ships two default roles (Member and Admin); the match is on
// the stable slug, falling back to an exact (case-insensitive) name match,
// so a custom role merely containing "admin" is not auto-classified.
func pylonIsAdmin(role pylonRole) bool {
if slug := strings.TrimSpace(role.Slug); slug != "" {
return strings.EqualFold(slug, "admin")
}
return strings.EqualFold(strings.TrimSpace(role.Name), "Admin")
}

View File

@@ -0,0 +1,76 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package drivers
import (
"context"
"os"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.probo.inc/probo/pkg/coredata"
)
func TestPylonDriver(t *testing.T) {
t.Parallel()
rec := newRecorder(t, "testdata/pylon", "PYLON_API_KEY")
client := newVCRClient(rec, bearerAuth(os.Getenv("PYLON_API_KEY")))
driver := NewPylonDriver(client)
records, err := driver.ListAccounts(context.Background())
require.NoError(t, err)
require.Len(t, records, 3)
admin := records[0]
assert.Equal(t, "user_1", admin.ExternalID)
assert.Equal(t, "alice@example.com", admin.Email)
assert.Equal(t, "Alice Admin", admin.FullName)
// role_id "role_admin" resolved through GET /user-roles.
assert.Equal(t, []string{"Admin"}, admin.Roles)
assert.True(t, admin.IsAdmin)
require.NotNil(t, admin.Active)
assert.True(t, *admin.Active)
assert.Equal(t, coredata.AccessReviewEntryAccountTypeUser, admin.AccountType)
member := records[1]
assert.Equal(t, []string{"Member"}, member.Roles)
assert.False(t, member.IsAdmin)
// No name → display name falls back to the email; "deactivated" status →
// Active false.
deactivated := records[2]
assert.Equal(t, "carol@example.com", deactivated.FullName)
assert.Equal(t, []string{"Member"}, deactivated.Roles)
require.NotNil(t, deactivated.Active)
assert.False(t, *deactivated.Active)
}
func TestPylonIsAdmin(t *testing.T) {
t.Parallel()
// The stable slug is preferred when present.
assert.True(t, pylonIsAdmin(pylonRole{Slug: "admin", Name: "Admin"}))
assert.False(t, pylonIsAdmin(pylonRole{Slug: "member", Name: "Member"}))
// A custom role named like an admin but with a non-admin slug is NOT an
// admin — the slug wins.
assert.False(t, pylonIsAdmin(pylonRole{Slug: "billing", Name: "Billing Admin"}))
// With no slug, the exact (case-insensitive) name is used.
assert.True(t, pylonIsAdmin(pylonRole{Name: "Admin"}))
assert.False(t, pylonIsAdmin(pylonRole{Name: "Billing Admin"}))
// An unresolved role (zero value: role_id not in the catalogue) is not admin.
assert.False(t, pylonIsAdmin(pylonRole{}))
}

View File

@@ -0,0 +1,35 @@
---
# Recorded live against GET /v3/organization/invited/users (Brevo, api-key
# header) on 2026-06-24, then anonymized: real ids, emails and locales are
# replaced with synthetic values, while the {users} wrapper and member shape
# are the verbatim live response — note is_owner is a JSON boolean (not the
# string the docs/SDK show), an `id` is present, and feature_access carries
# more keys than documented (transactional/phone/meetings/sequences) with a
# "full" level. The api-key header is stripped on save.
version: 2
interactions:
- id: 0
request:
proto: HTTP/1.1
proto_major: 1
proto_minor: 1
content_length: 0
host: api.brevo.com
headers:
Accept:
- application/json
url: https://api.brevo.com/v3/organization/invited/users
method: GET
response:
proto: HTTP/2.0
proto_major: 2
proto_minor: 0
content_length: -1
uncompressed: true
body: '{"users":[{"id":"000000000000000000000001","email":"owner@example.com","is_owner":true,"status":"active","feature_access":{"marketing":"owner","conversations":"owner","crm":"owner","transactional":"owner","phone":"owner"},"locale":"fr_FR"},{"id":"000000000000000000000002","email":"member@example.com","is_owner":false,"status":"active","feature_access":{"marketing":"none","conversations":"none","crm":"full","transactional":"full","phone":"none","meetings":"none","sequences":"none"},"locale":"fr_FR"},{"id":"000000000000000000000003","email":"viewer@example.com","is_owner":false,"status":"active","feature_access":{"marketing":"none","conversations":"none","crm":"full","transactional":"full","phone":"none","meetings":"none","sequences":"none"},"locale":"en_US"}]}'
headers:
Content-Type:
- application/json
status: 200 OK
code: 200
duration: 124ms

View File

@@ -0,0 +1,71 @@
---
# Hand-authored fixture for GET /v2/users against an incident.io
# organization. The user object shape (id, name, email, role, base_role,
# custom_roles) and the {users, pagination_meta} wrapper mirror the
# documented response. Synthetic IDs/emails only.
#
# Two interactions deliberately split three users across two pages, where the
# FIRST page returns fewer than page_size rows while still handing back a
# non-empty `after` cursor. This regression-guards the pagination terminator:
# the driver must follow the cursor (and return all three users) rather than
# stop early on the short first page.
version: 2
interactions:
- id: 0
request:
proto: HTTP/1.1
proto_major: 1
proto_minor: 1
content_length: 0
host: api.incident.io
form:
page_size:
- "100"
headers:
Accept:
- application/json
url: https://api.incident.io/v2/users?page_size=100
method: GET
response:
proto: HTTP/2.0
proto_major: 2
proto_minor: 0
content_length: -1
uncompressed: true
body: '{"users":[{"id":"01ABCOWNER","name":"Lisa Curtis","email":"lisa@example.com","role":"viewer","base_role":{"id":"r1","name":"Owner","slug":"owner"},"custom_roles":[],"slack_user_id":"U01"},{"id":"01DEFRESP","name":"Sam Responder","email":"sam@example.com","role":"responder","base_role":{"id":"r2","name":"Responder","slug":"responder"},"custom_roles":[{"id":"c1","name":"On-call Lead","slug":"on-call-lead"}],"slack_user_id":"U02"}],"pagination_meta":{"after":"01PAGE2CURSOR0000000000000","page_size":100,"total_record_count":3}}'
headers:
Content-Type:
- application/json
status: 200 OK
code: 200
duration: 150ms
- id: 1
request:
proto: HTTP/1.1
proto_major: 1
proto_minor: 1
content_length: 0
host: api.incident.io
form:
after:
- "01PAGE2CURSOR0000000000000"
page_size:
- "100"
headers:
Accept:
- application/json
url: https://api.incident.io/v2/users?after=01PAGE2CURSOR0000000000000&page_size=100
method: GET
response:
proto: HTTP/2.0
proto_major: 2
proto_minor: 0
content_length: -1
uncompressed: true
body: '{"users":[{"id":"01GHIADMIN","name":"","email":"legacy-admin@example.com","role":"administrator","base_role":null,"custom_roles":[],"slack_user_id":""}],"pagination_meta":{"after":"","page_size":100,"total_record_count":3}}'
headers:
Content-Type:
- application/json
status: 200 OK
code: 200
duration: 140ms

View File

@@ -0,0 +1,40 @@
---
# Recorded live against GET /api/v1/organization/members (with an OpenRouter
# organization management key) on 2026-06-24, then anonymized: the real
# member's id, name and email are replaced with synthetic values, while the
# {data, total_count} wrapper and member shape (id, first_name, last_name,
# email, role) are the verbatim live response shape. The Authorization header
# is stripped on save. The org:member role and null-name fallbacks (absent
# from this single-admin org) are covered by unit tests in the driver test.
version: 2
interactions:
- id: 0
request:
proto: HTTP/1.1
proto_major: 1
proto_minor: 1
content_length: 0
host: openrouter.ai
form:
limit:
- "100"
offset:
- "0"
headers:
Accept:
- application/json
url: https://openrouter.ai/api/v1/organization/members?limit=100&offset=0
method: GET
response:
proto: HTTP/2.0
proto_major: 2
proto_minor: 0
content_length: -1
uncompressed: true
body: '{"data":[{"id":"user_000000000000000000000admin","first_name":"Ada","last_name":"Admin","email":"ada.admin@example.com","role":"org:admin"}],"total_count":1}'
headers:
Content-Type:
- application/json
status: 200 OK
code: 200
duration: 454ms

View File

@@ -0,0 +1,64 @@
---
# Hand-authored fixture for the Pylon access-review driver. The driver first
# resolves the organization's role catalogue (GET /user-roles), then lists
# members (GET /users) and maps each user's opaque role_id to a role name.
# The user/role object shapes mirror the documented OpenAPI schema. Synthetic
# IDs/emails only.
version: 2
interactions:
- id: 0
request:
proto: HTTP/1.1
proto_major: 1
proto_minor: 1
content_length: 0
host: api.usepylon.com
form:
limit:
- "100"
headers:
Accept:
- application/json
url: https://api.usepylon.com/user-roles?limit=100
method: GET
response:
proto: HTTP/2.0
proto_major: 2
proto_minor: 0
content_length: -1
uncompressed: true
body: '{"data":[{"id":"role_admin","name":"Admin","slug":"admin"},{"id":"role_member","name":"Member","slug":"member"}],"pagination":{"cursor":"","has_next_page":false},"request_id":"req_roles_1"}'
headers:
Content-Type:
- application/json
status: 200 OK
code: 200
duration: 120ms
- id: 1
request:
proto: HTTP/1.1
proto_major: 1
proto_minor: 1
content_length: 0
host: api.usepylon.com
form:
limit:
- "100"
headers:
Accept:
- application/json
url: https://api.usepylon.com/users?limit=100
method: GET
response:
proto: HTTP/2.0
proto_major: 2
proto_minor: 0
content_length: -1
uncompressed: true
body: '{"data":[{"id":"user_1","email":"alice@example.com","name":"Alice Admin","role_id":"role_admin","status":"active"},{"id":"user_2","email":"bob@example.com","name":"Bob Member","role_id":"role_member","status":"active"},{"id":"user_3","email":"carol@example.com","name":"","role_id":"role_member","status":"deactivated"}],"pagination":{"cursor":"","has_next_page":false},"request_id":"req_users_1"}'
headers:
Content-Type:
- application/json
status: 200 OK
code: 200
duration: 140ms

View File

@@ -51,12 +51,13 @@ func newRecorder(t *testing.T, cassettePath string, envVar string) *recorder.Rec
)),
recorder.WithHook(func(i *cassette.Interaction) error {
i.Request.Headers.Del("Authorization")
// Providers like Anthropic (x-api-key) and SigNoz
// (SIGNOZ-API-KEY) authenticate via a custom header rather
// than Authorization; strip those too so a re-record never
// persists a raw key.
// Providers like Anthropic (x-api-key), SigNoz
// (SIGNOZ-API-KEY) and Brevo (api-key) authenticate via a
// custom header rather than Authorization; strip those too so a
// re-record never persists a raw key.
i.Request.Headers.Del("X-Api-Key")
i.Request.Headers.Del("Signoz-Api-Key")
i.Request.Headers.Del("Api-Key")
return nil
}, recorder.BeforeSaveHook),