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:
181
pkg/accessreview/drivers/brevo.go
Normal file
181
pkg/accessreview/drivers/brevo.go
Normal file
@@ -0,0 +1,181 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package drivers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"go.probo.inc/probo/pkg/coredata"
|
||||
)
|
||||
|
||||
const brevoInvitedUsersEndpoint = "https://api.brevo.com/v3/organization/invited/users"
|
||||
|
||||
// BrevoDriver lists the invited users (organization seats) of a single Brevo
|
||||
// account. The API key (sent in the api-key header by the connection
|
||||
// transport) is bound to one account, so GET /v3/organization/invited/users
|
||||
// returns every invited user of that account with no tenant selector and no
|
||||
// pagination.
|
||||
type BrevoDriver struct {
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
var _ Driver = (*BrevoDriver)(nil)
|
||||
|
||||
type brevoInvitedUser struct {
|
||||
// ID is Brevo's stable user identifier (a Mongo-style ObjectID). The
|
||||
// documented schema omits it, but the live API returns it, so it is
|
||||
// preferred over the email as the ExternalID.
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
// IsOwner flags the account owner. The live API returns a JSON boolean
|
||||
// while older docs/SDK show a string ("true"/"false"); decoded as
|
||||
// RawMessage and read via brevoIsOwner to tolerate both shapes.
|
||||
IsOwner json.RawMessage `json:"is_owner"`
|
||||
// Status is the invitation state: "active" or "pending".
|
||||
Status string `json:"status"`
|
||||
// FeatureAccess maps a feature area (marketing / crm / conversations /
|
||||
// transactional / phone / …) to the user's access level on it. Values are
|
||||
// strings (e.g. "owner", "full", "none"); decoded as RawMessage so a
|
||||
// non-string shape degrades gracefully instead of failing the whole
|
||||
// decode.
|
||||
FeatureAccess map[string]json.RawMessage `json:"feature_access"`
|
||||
}
|
||||
|
||||
type brevoInvitedUsersResponse struct {
|
||||
Users []brevoInvitedUser `json:"users"`
|
||||
}
|
||||
|
||||
func NewBrevoDriver(httpClient *http.Client) *BrevoDriver {
|
||||
return &BrevoDriver{httpClient: httpClient}
|
||||
}
|
||||
|
||||
func (d *BrevoDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, brevoInvitedUsersEndpoint, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot create brevo invited users request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
httpResp, err := d.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot execute brevo invited users request: %w", err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
_ = httpResp.Body.Close()
|
||||
}()
|
||||
|
||||
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("cannot fetch brevo invited users: unexpected status %d", httpResp.StatusCode)
|
||||
}
|
||||
|
||||
var resp brevoInvitedUsersResponse
|
||||
if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil {
|
||||
return nil, fmt.Errorf("cannot decode brevo invited users response: %w", err)
|
||||
}
|
||||
|
||||
records := make([]AccountRecord, 0, len(resp.Users))
|
||||
|
||||
for _, u := range resp.Users {
|
||||
email := strings.TrimSpace(u.Email)
|
||||
if email == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
records = append(records, AccountRecord{
|
||||
Email: email,
|
||||
// Brevo's invited-users API exposes no display name.
|
||||
FullName: email,
|
||||
Roles: brevoRoles(u.FeatureAccess),
|
||||
Active: activeFromStatus(u.Status),
|
||||
IsAdmin: brevoIsOwner(u.IsOwner),
|
||||
MFAStatus: coredata.MFAStatusUnknown,
|
||||
AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown,
|
||||
AccountType: coredata.AccessReviewEntryAccountTypeUser,
|
||||
ExternalID: brevoExternalID(u, email),
|
||||
})
|
||||
}
|
||||
|
||||
return records, nil
|
||||
}
|
||||
|
||||
// brevoExternalID prefers Brevo's stable user id, falling back to the email
|
||||
// (the only other durable identifier) when an account has none.
|
||||
func brevoExternalID(u brevoInvitedUser, email string) string {
|
||||
if id := strings.TrimSpace(u.ID); id != "" {
|
||||
return id
|
||||
}
|
||||
|
||||
return email
|
||||
}
|
||||
|
||||
// brevoIsOwner reads the account-owner flag, tolerating both the JSON boolean
|
||||
// the live API returns and the string ("true"/"false") shown in older
|
||||
// docs/SDK.
|
||||
func brevoIsOwner(raw json.RawMessage) bool {
|
||||
if len(raw) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
var b bool
|
||||
if err := json.Unmarshal(raw, &b); err == nil {
|
||||
return b
|
||||
}
|
||||
|
||||
var s string
|
||||
if err := json.Unmarshal(raw, &s); err == nil {
|
||||
return strings.EqualFold(strings.TrimSpace(s), "true")
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// brevoRoles summarises an invited user's per-feature access levels
|
||||
// (marketing / crm / conversations) into a de-duplicated, sorted set of role
|
||||
// labels. Each feature_access value is normally a string such as "owner";
|
||||
// the "none" level and any non-string shape are skipped so the result holds
|
||||
// only the access levels actually granted.
|
||||
func brevoRoles(featureAccess map[string]json.RawMessage) []string {
|
||||
seen := make(map[string]struct{})
|
||||
|
||||
for _, raw := range featureAccess {
|
||||
var level string
|
||||
if err := json.Unmarshal(raw, &level); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
level = strings.TrimSpace(level)
|
||||
if level == "" || strings.EqualFold(level, "none") {
|
||||
continue
|
||||
}
|
||||
|
||||
seen[level] = struct{}{}
|
||||
}
|
||||
|
||||
roles := make([]string, 0, len(seen))
|
||||
for level := range seen {
|
||||
roles = append(roles, level)
|
||||
}
|
||||
|
||||
sort.Strings(roles)
|
||||
|
||||
return roles
|
||||
}
|
||||
107
pkg/accessreview/drivers/brevo_test.go
Normal file
107
pkg/accessreview/drivers/brevo_test.go
Normal file
@@ -0,0 +1,107 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package drivers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.probo.inc/probo/pkg/coredata"
|
||||
)
|
||||
|
||||
func TestBrevoDriver(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rec := newRecorder(t, "testdata/brevo", "BREVO_API_KEY")
|
||||
// Brevo authenticates via the api-key header, not Authorization.
|
||||
client := newVCRClientWithHeader(rec, "api-key", os.Getenv("BREVO_API_KEY"))
|
||||
|
||||
driver := NewBrevoDriver(client)
|
||||
records, err := driver.ListAccounts(context.Background())
|
||||
require.NoError(t, err)
|
||||
require.Len(t, records, 3)
|
||||
|
||||
// Cassette recorded live (api-key header), then anonymized: the owner has
|
||||
// every feature at "owner"; the two members have crm/transactional "full"
|
||||
// and the rest "none".
|
||||
owner := records[0]
|
||||
assert.Equal(t, "000000000000000000000001", owner.ExternalID)
|
||||
assert.Equal(t, "owner@example.com", owner.Email)
|
||||
assert.Equal(t, "owner@example.com", owner.FullName)
|
||||
assert.True(t, owner.IsAdmin)
|
||||
require.NotNil(t, owner.Active)
|
||||
assert.True(t, *owner.Active)
|
||||
assert.Equal(t, []string{"owner"}, owner.Roles)
|
||||
assert.Equal(t, coredata.AccessReviewEntryAccountTypeUser, owner.AccountType)
|
||||
|
||||
// Non-owner; "none" levels filtered, the remaining "full" de-duplicated.
|
||||
member := records[1]
|
||||
assert.Equal(t, "000000000000000000000002", member.ExternalID)
|
||||
assert.False(t, member.IsAdmin)
|
||||
assert.Equal(t, []string{"full"}, member.Roles)
|
||||
require.NotNil(t, member.Active)
|
||||
assert.True(t, *member.Active)
|
||||
|
||||
// The third record is asserted too, so a swap or corruption is caught.
|
||||
viewer := records[2]
|
||||
assert.Equal(t, "000000000000000000000003", viewer.ExternalID)
|
||||
assert.Equal(t, "viewer@example.com", viewer.Email)
|
||||
assert.False(t, viewer.IsAdmin)
|
||||
assert.Equal(t, []string{"full"}, viewer.Roles)
|
||||
}
|
||||
|
||||
func TestBrevoExternalID(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// The stable id is preferred when present.
|
||||
assert.Equal(t, "abc123", brevoExternalID(brevoInvitedUser{ID: "abc123"}, "x@example.com"))
|
||||
// With no id, the email is the fallback.
|
||||
assert.Equal(t, "x@example.com", brevoExternalID(brevoInvitedUser{}, "x@example.com"))
|
||||
assert.Equal(t, "x@example.com", brevoExternalID(brevoInvitedUser{ID: " "}, "x@example.com"))
|
||||
}
|
||||
|
||||
func TestBrevoIsOwner(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// The live API returns a JSON boolean; older docs/SDK show a string.
|
||||
// Both must be tolerated.
|
||||
assert.True(t, brevoIsOwner(json.RawMessage(`true`)))
|
||||
assert.False(t, brevoIsOwner(json.RawMessage(`false`)))
|
||||
assert.True(t, brevoIsOwner(json.RawMessage(`"true"`)))
|
||||
assert.False(t, brevoIsOwner(json.RawMessage(`"false"`)))
|
||||
assert.False(t, brevoIsOwner(nil))
|
||||
}
|
||||
|
||||
func TestBrevoRoles(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Distinct non-"none" levels, sorted; "none" is filtered out.
|
||||
roles := brevoRoles(map[string]json.RawMessage{
|
||||
"marketing": json.RawMessage(`"owner"`),
|
||||
"conversations": json.RawMessage(`"owner"`),
|
||||
"crm": json.RawMessage(`"none"`),
|
||||
})
|
||||
assert.Equal(t, []string{"owner"}, roles)
|
||||
|
||||
// All "none" → no roles.
|
||||
assert.Empty(t, brevoRoles(map[string]json.RawMessage{"crm": json.RawMessage(`"none"`)}))
|
||||
|
||||
// A non-string shape is ignored rather than failing.
|
||||
assert.Empty(t, brevoRoles(map[string]json.RawMessage{"crm": json.RawMessage(`{"x":1}`)}))
|
||||
}
|
||||
@@ -17,6 +17,7 @@ package drivers
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.probo.inc/probo/pkg/coredata"
|
||||
@@ -83,3 +84,25 @@ func parseRFC3339Ptr(s string) *time.Time {
|
||||
|
||||
return &t
|
||||
}
|
||||
|
||||
// activeFromStatus maps a provider status string to the three-valued Active
|
||||
// signal for providers whose only "live" state is the literal "active" and
|
||||
// whose remaining status enum is not otherwise enumerated: "active" → active,
|
||||
// an empty status → nil (no signal), and any other non-empty status →
|
||||
// inactive. Used by drivers like Pylon and Brevo; a provider with a fully
|
||||
// known status enum (e.g. Render's active/inactive) maps its own values
|
||||
// explicitly instead, so an unrecognised value stays nil rather than false.
|
||||
func activeFromStatus(status string) *bool {
|
||||
switch strings.ToLower(strings.TrimSpace(status)) {
|
||||
case "active":
|
||||
active := true
|
||||
|
||||
return &active
|
||||
case "":
|
||||
return nil
|
||||
default:
|
||||
inactive := false
|
||||
|
||||
return &inactive
|
||||
}
|
||||
}
|
||||
|
||||
45
pkg/accessreview/drivers/driver_test.go
Normal file
45
pkg/accessreview/drivers/driver_test.go
Normal file
@@ -0,0 +1,45 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package drivers
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestActiveFromStatus(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// "active" → active, case-insensitive.
|
||||
for _, s := range []string{"active", "ACTIVE", " Active "} {
|
||||
got := activeFromStatus(s)
|
||||
require.NotNilf(t, got, "status %q", s)
|
||||
assert.Truef(t, *got, "status %q", s)
|
||||
}
|
||||
|
||||
// Empty/whitespace status → no signal (nil).
|
||||
assert.Nil(t, activeFromStatus(""))
|
||||
assert.Nil(t, activeFromStatus(" "))
|
||||
|
||||
// Any other non-empty status is treated as inactive (Brevo "pending",
|
||||
// Pylon "deactivated", and any unrecognised future value).
|
||||
for _, s := range []string{"pending", "deactivated", "disabled", "whatever"} {
|
||||
got := activeFromStatus(s)
|
||||
require.NotNilf(t, got, "status %q", s)
|
||||
assert.Falsef(t, *got, "status %q", s)
|
||||
}
|
||||
}
|
||||
226
pkg/accessreview/drivers/incidentio.go
Normal file
226
pkg/accessreview/drivers/incidentio.go
Normal file
@@ -0,0 +1,226 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package drivers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"go.probo.inc/probo/pkg/coredata"
|
||||
)
|
||||
|
||||
const (
|
||||
incidentIOUsersEndpoint = "https://api.incident.io/v2/users"
|
||||
// incidentIOPageSize is the page size requested from GET /v2/users (the
|
||||
// API defaults to 25 and accepts up to 10000).
|
||||
incidentIOPageSize = 100
|
||||
)
|
||||
|
||||
// IncidentIODriver lists the users of a single incident.io organization. The
|
||||
// API key (Bearer) is bound to one organization, so GET /v2/users returns
|
||||
// every user of that organization with no tenant selector.
|
||||
type IncidentIODriver struct {
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
var _ Driver = (*IncidentIODriver)(nil)
|
||||
|
||||
type incidentIORole struct {
|
||||
Name string `json:"name"`
|
||||
Slug string `json:"slug"`
|
||||
}
|
||||
|
||||
type incidentIOUser struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
// Role is the deprecated coarse role enum: owner / administrator /
|
||||
// responder / viewer / unset. base_role / custom_roles are the live
|
||||
// RBAC roles and take precedence when present.
|
||||
Role string `json:"role"`
|
||||
BaseRole *incidentIORole `json:"base_role"`
|
||||
CustomRoles []incidentIORole `json:"custom_roles"`
|
||||
}
|
||||
|
||||
type incidentIOUsersResponse struct {
|
||||
Users []incidentIOUser `json:"users"`
|
||||
PaginationMeta struct {
|
||||
After string `json:"after"`
|
||||
} `json:"pagination_meta"`
|
||||
}
|
||||
|
||||
func NewIncidentIODriver(httpClient *http.Client) *IncidentIODriver {
|
||||
return &IncidentIODriver{httpClient: httpClient}
|
||||
}
|
||||
|
||||
func (d *IncidentIODriver) ListAccounts(ctx context.Context) ([]AccountRecord, error) {
|
||||
var records []AccountRecord
|
||||
|
||||
after := ""
|
||||
|
||||
for range maxPaginationPages {
|
||||
resp, err := d.fetchUsersPage(ctx, after)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, u := range resp.Users {
|
||||
email := strings.TrimSpace(u.Email)
|
||||
if email == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
records = append(records, AccountRecord{
|
||||
Email: email,
|
||||
FullName: incidentIOFullName(u, email),
|
||||
Roles: incidentIORoles(u),
|
||||
IsAdmin: incidentIOIsAdmin(u),
|
||||
MFAStatus: coredata.MFAStatusUnknown,
|
||||
AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown,
|
||||
AccountType: coredata.AccessReviewEntryAccountTypeUser,
|
||||
ExternalID: strings.TrimSpace(u.ID),
|
||||
})
|
||||
}
|
||||
|
||||
// The `after` cursor is the authoritative end-of-results signal: stop
|
||||
// when it is empty. A short page is NOT treated as the end (incident.io
|
||||
// may return fewer than page_size rows while more pages remain). The
|
||||
// empty-page guard is only a backstop against an API that never clears
|
||||
// the cursor, so the loop cannot spin past the data.
|
||||
if resp.PaginationMeta.After == "" || len(resp.Users) == 0 {
|
||||
return records, nil
|
||||
}
|
||||
|
||||
after = resp.PaginationMeta.After
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("cannot list all incident.io users: %w", ErrPaginationLimitReached)
|
||||
}
|
||||
|
||||
func (d *IncidentIODriver) fetchUsersPage(ctx context.Context, after string) (*incidentIOUsersResponse, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, incidentIOUsersEndpoint, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot create incident.io users request: %w", err)
|
||||
}
|
||||
|
||||
q := req.URL.Query()
|
||||
q.Set("page_size", strconv.Itoa(incidentIOPageSize))
|
||||
|
||||
if after != "" {
|
||||
q.Set("after", after)
|
||||
}
|
||||
|
||||
req.URL.RawQuery = q.Encode()
|
||||
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
httpResp, err := d.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot execute incident.io users request: %w", err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
_ = httpResp.Body.Close()
|
||||
}()
|
||||
|
||||
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("cannot fetch incident.io users: unexpected status %d", httpResp.StatusCode)
|
||||
}
|
||||
|
||||
var resp incidentIOUsersResponse
|
||||
if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil {
|
||||
return nil, fmt.Errorf("cannot decode incident.io users response: %w", err)
|
||||
}
|
||||
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
func incidentIOFullName(u incidentIOUser, fallback string) string {
|
||||
if name := strings.TrimSpace(u.Name); name != "" {
|
||||
return name
|
||||
}
|
||||
|
||||
return fallback
|
||||
}
|
||||
|
||||
// incidentIORoles returns the user's roles, preferring the live RBAC roles
|
||||
// (base_role + custom_roles) and falling back to the deprecated `role` enum
|
||||
// only when no RBAC role is present.
|
||||
func incidentIORoles(u incidentIOUser) []string {
|
||||
roles := []string{}
|
||||
|
||||
if u.BaseRole != nil {
|
||||
if name := strings.TrimSpace(u.BaseRole.Name); name != "" {
|
||||
roles = append(roles, name)
|
||||
}
|
||||
}
|
||||
|
||||
for _, r := range u.CustomRoles {
|
||||
if name := strings.TrimSpace(r.Name); name != "" {
|
||||
roles = append(roles, name)
|
||||
}
|
||||
}
|
||||
|
||||
if len(roles) > 0 {
|
||||
return roles
|
||||
}
|
||||
|
||||
if name := incidentIODeprecatedRoleName(u.Role); name != "" {
|
||||
return []string{name}
|
||||
}
|
||||
|
||||
return []string{}
|
||||
}
|
||||
|
||||
// incidentIODeprecatedRoleName maps the deprecated coarse role enum to a
|
||||
// display label, returning "" for "unset" or an absent value.
|
||||
func incidentIODeprecatedRoleName(role string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(role)) {
|
||||
case "owner":
|
||||
return "Owner"
|
||||
case "administrator":
|
||||
return "Administrator"
|
||||
case "responder":
|
||||
return "Responder"
|
||||
case "viewer":
|
||||
return "Viewer"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// incidentIOIsAdmin reports whether the user holds an administrative role. It
|
||||
// prefers the live base_role slug and falls back to the deprecated role enum;
|
||||
// both "owner" and "administrator" are administrative.
|
||||
func incidentIOIsAdmin(u incidentIOUser) bool {
|
||||
if u.BaseRole != nil && strings.TrimSpace(u.BaseRole.Slug) != "" {
|
||||
return incidentIOAdminSlug(u.BaseRole.Slug)
|
||||
}
|
||||
|
||||
return incidentIOAdminSlug(u.Role)
|
||||
}
|
||||
|
||||
func incidentIOAdminSlug(slug string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(slug)) {
|
||||
case "owner", "administrator":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
108
pkg/accessreview/drivers/incidentio_test.go
Normal file
108
pkg/accessreview/drivers/incidentio_test.go
Normal file
@@ -0,0 +1,108 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package drivers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.probo.inc/probo/pkg/coredata"
|
||||
)
|
||||
|
||||
func TestIncidentIODriver(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rec := newRecorder(t, "testdata/incidentio", "INCIDENT_IO_API_KEY")
|
||||
client := newVCRClient(rec, bearerAuth(os.Getenv("INCIDENT_IO_API_KEY")))
|
||||
|
||||
driver := NewIncidentIODriver(client)
|
||||
records, err := driver.ListAccounts(context.Background())
|
||||
require.NoError(t, err)
|
||||
// Three records spread across two pages: the cassette's first page is
|
||||
// short (2 < page_size) but carries a non-empty `after`, so getting all
|
||||
// three proves the driver follows the cursor instead of stopping early.
|
||||
require.Len(t, records, 3)
|
||||
|
||||
owner := records[0]
|
||||
assert.Equal(t, "01ABCOWNER", owner.ExternalID)
|
||||
assert.Equal(t, "lisa@example.com", owner.Email)
|
||||
assert.Equal(t, "Lisa Curtis", owner.FullName)
|
||||
assert.Equal(t, []string{"Owner"}, owner.Roles)
|
||||
assert.True(t, owner.IsAdmin)
|
||||
assert.Equal(t, coredata.AccessReviewEntryAccountTypeUser, owner.AccountType)
|
||||
// /v2/users carries no account-status field, so Active stays nil.
|
||||
assert.Nil(t, owner.Active)
|
||||
|
||||
// Live base_role + custom_roles are unioned; a responder is not an admin.
|
||||
responder := records[1]
|
||||
assert.Equal(t, []string{"Responder", "On-call Lead"}, responder.Roles)
|
||||
assert.False(t, responder.IsAdmin)
|
||||
|
||||
// base_role null → falls back to the deprecated role enum; empty name →
|
||||
// the display name falls back to the email.
|
||||
legacy := records[2]
|
||||
assert.Equal(t, "legacy-admin@example.com", legacy.FullName)
|
||||
assert.Equal(t, []string{"Administrator"}, legacy.Roles)
|
||||
assert.True(t, legacy.IsAdmin)
|
||||
}
|
||||
|
||||
func TestIncidentIORoles(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// base_role + custom_roles are unioned, base first.
|
||||
full := incidentIOUser{
|
||||
BaseRole: &incidentIORole{Name: "Owner", Slug: "owner"},
|
||||
CustomRoles: []incidentIORole{{Name: "On-call Lead", Slug: "on-call-lead"}},
|
||||
Role: "viewer",
|
||||
}
|
||||
assert.Equal(t, []string{"Owner", "On-call Lead"}, incidentIORoles(full))
|
||||
|
||||
// No live RBAC role → falls back to the deprecated enum.
|
||||
assert.Equal(t, []string{"Responder"}, incidentIORoles(incidentIOUser{Role: "responder"}))
|
||||
|
||||
// No role at all → empty slice.
|
||||
assert.Equal(t, []string{}, incidentIORoles(incidentIOUser{Role: "unset"}))
|
||||
}
|
||||
|
||||
func TestIncidentIOIsAdmin(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// The live base_role slug wins, including the "administrator" slug that
|
||||
// the cassette does not exercise.
|
||||
assert.True(t, incidentIOIsAdmin(incidentIOUser{BaseRole: &incidentIORole{Slug: "owner"}}))
|
||||
assert.True(t, incidentIOIsAdmin(incidentIOUser{BaseRole: &incidentIORole{Slug: "administrator"}}))
|
||||
assert.False(t, incidentIOIsAdmin(incidentIOUser{BaseRole: &incidentIORole{Slug: "responder"}}))
|
||||
// A non-admin base_role is NOT overridden by an admin deprecated role.
|
||||
assert.False(t, incidentIOIsAdmin(incidentIOUser{BaseRole: &incidentIORole{Slug: "viewer"}, Role: "administrator"}))
|
||||
// No base_role → falls back to the deprecated role enum.
|
||||
assert.True(t, incidentIOIsAdmin(incidentIOUser{Role: "administrator"}))
|
||||
assert.True(t, incidentIOIsAdmin(incidentIOUser{Role: "owner"}))
|
||||
assert.False(t, incidentIOIsAdmin(incidentIOUser{Role: "viewer"}))
|
||||
}
|
||||
|
||||
func TestIncidentIODeprecatedRoleName(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
assert.Equal(t, "Owner", incidentIODeprecatedRoleName("owner"))
|
||||
assert.Equal(t, "Administrator", incidentIODeprecatedRoleName("administrator"))
|
||||
assert.Equal(t, "Responder", incidentIODeprecatedRoleName("responder"))
|
||||
assert.Equal(t, "Viewer", incidentIODeprecatedRoleName("viewer"))
|
||||
// "unset" and an absent value map to no role.
|
||||
assert.Equal(t, "", incidentIODeprecatedRoleName("unset"))
|
||||
assert.Equal(t, "", incidentIODeprecatedRoleName(""))
|
||||
}
|
||||
171
pkg/accessreview/drivers/openrouter.go
Normal file
171
pkg/accessreview/drivers/openrouter.go
Normal file
@@ -0,0 +1,171 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package drivers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"go.probo.inc/probo/pkg/coredata"
|
||||
)
|
||||
|
||||
const (
|
||||
openRouterMembersEndpoint = "https://openrouter.ai/api/v1/organization/members"
|
||||
// openRouterPageSize is the maximum page size GET /organization/members
|
||||
// accepts (limit must be between 1 and 100).
|
||||
openRouterPageSize = 100
|
||||
)
|
||||
|
||||
// OpenRouterDriver lists the members of a single OpenRouter organization. The
|
||||
// management (provisioning) API key is bound to one organization, so GET
|
||||
// /api/v1/organization/members returns every member of that organization
|
||||
// with no tenant selector.
|
||||
type OpenRouterDriver struct {
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
var _ Driver = (*OpenRouterDriver)(nil)
|
||||
|
||||
type openRouterMember struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
FirstName *string `json:"first_name"`
|
||||
LastName *string `json:"last_name"`
|
||||
// Role is OpenRouter's organization role enum: "org:admin" or
|
||||
// "org:member".
|
||||
Role string `json:"role"`
|
||||
}
|
||||
|
||||
type openRouterMembersResponse struct {
|
||||
Data []openRouterMember `json:"data"`
|
||||
TotalCount int `json:"total_count"`
|
||||
}
|
||||
|
||||
func NewOpenRouterDriver(httpClient *http.Client) *OpenRouterDriver {
|
||||
return &OpenRouterDriver{httpClient: httpClient}
|
||||
}
|
||||
|
||||
func (d *OpenRouterDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error) {
|
||||
var records []AccountRecord
|
||||
|
||||
offset := 0
|
||||
|
||||
for range maxPaginationPages {
|
||||
resp, err := d.fetchMembersPage(ctx, offset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, m := range resp.Data {
|
||||
email := strings.TrimSpace(m.Email)
|
||||
if email == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
records = append(records, AccountRecord{
|
||||
Email: email,
|
||||
FullName: openRouterFullName(m, email),
|
||||
Roles: openRouterRoles(m.Role),
|
||||
IsAdmin: m.Role == "org:admin",
|
||||
MFAStatus: coredata.MFAStatusUnknown,
|
||||
AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown,
|
||||
AccountType: coredata.AccessReviewEntryAccountTypeUser,
|
||||
ExternalID: strings.TrimSpace(m.ID),
|
||||
})
|
||||
}
|
||||
|
||||
offset += len(resp.Data)
|
||||
if len(resp.Data) < openRouterPageSize || offset >= resp.TotalCount {
|
||||
return records, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("cannot list all openrouter members: %w", ErrPaginationLimitReached)
|
||||
}
|
||||
|
||||
func (d *OpenRouterDriver) fetchMembersPage(ctx context.Context, offset int) (*openRouterMembersResponse, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, openRouterMembersEndpoint, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot create openrouter members request: %w", err)
|
||||
}
|
||||
|
||||
q := req.URL.Query()
|
||||
q.Set("limit", strconv.Itoa(openRouterPageSize))
|
||||
q.Set("offset", strconv.Itoa(offset))
|
||||
req.URL.RawQuery = q.Encode()
|
||||
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
httpResp, err := d.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot execute openrouter members request: %w", err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
_ = httpResp.Body.Close()
|
||||
}()
|
||||
|
||||
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("cannot fetch openrouter members: unexpected status %d", httpResp.StatusCode)
|
||||
}
|
||||
|
||||
var resp openRouterMembersResponse
|
||||
if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil {
|
||||
return nil, fmt.Errorf("cannot decode openrouter members response: %w", err)
|
||||
}
|
||||
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
func openRouterFullName(m openRouterMember, fallback string) string {
|
||||
first := ""
|
||||
if m.FirstName != nil {
|
||||
first = strings.TrimSpace(*m.FirstName)
|
||||
}
|
||||
|
||||
last := ""
|
||||
if m.LastName != nil {
|
||||
last = strings.TrimSpace(*m.LastName)
|
||||
}
|
||||
|
||||
full := strings.TrimSpace(first + " " + last)
|
||||
if full != "" {
|
||||
return full
|
||||
}
|
||||
|
||||
return fallback
|
||||
}
|
||||
|
||||
// openRouterRoles maps OpenRouter's organization role enum
|
||||
// (org:admin / org:member) to a display label, preserving any unknown
|
||||
// future role verbatim.
|
||||
func openRouterRoles(role string) []string {
|
||||
switch role {
|
||||
case "org:admin":
|
||||
return []string{"Admin"}
|
||||
case "org:member":
|
||||
return []string{"Member"}
|
||||
default:
|
||||
if strings.TrimSpace(role) != "" {
|
||||
return []string{role}
|
||||
}
|
||||
|
||||
return []string{}
|
||||
}
|
||||
}
|
||||
74
pkg/accessreview/drivers/openrouter_test.go
Normal file
74
pkg/accessreview/drivers/openrouter_test.go
Normal file
@@ -0,0 +1,74 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package drivers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.probo.inc/probo/pkg/coredata"
|
||||
)
|
||||
|
||||
func TestOpenRouterDriver(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rec := newRecorder(t, "testdata/openrouter", "OPENROUTER_API_KEY")
|
||||
client := newVCRClient(rec, bearerAuth(os.Getenv("OPENROUTER_API_KEY")))
|
||||
|
||||
driver := NewOpenRouterDriver(client)
|
||||
records, err := driver.ListAccounts(context.Background())
|
||||
require.NoError(t, err)
|
||||
require.Len(t, records, 1)
|
||||
|
||||
// Cassette recorded live against an OpenRouter organization (single admin
|
||||
// member), then anonymized.
|
||||
admin := records[0]
|
||||
assert.Equal(t, "user_000000000000000000000admin", admin.ExternalID)
|
||||
assert.Equal(t, "ada.admin@example.com", admin.Email)
|
||||
assert.Equal(t, "Ada Admin", admin.FullName)
|
||||
assert.Equal(t, []string{"Admin"}, admin.Roles)
|
||||
assert.True(t, admin.IsAdmin)
|
||||
assert.Equal(t, coredata.AccessReviewEntryAccountTypeUser, admin.AccountType)
|
||||
// The members endpoint carries no account-status field, so Active stays nil.
|
||||
assert.Nil(t, admin.Active)
|
||||
}
|
||||
|
||||
func TestOpenRouterRoles(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
assert.Equal(t, []string{"Admin"}, openRouterRoles("org:admin"))
|
||||
assert.Equal(t, []string{"Member"}, openRouterRoles("org:member"))
|
||||
// An unknown future role is preserved verbatim; an empty role yields none.
|
||||
assert.Equal(t, []string{"org:billing"}, openRouterRoles("org:billing"))
|
||||
assert.Equal(t, []string{}, openRouterRoles(""))
|
||||
}
|
||||
|
||||
func TestOpenRouterFullName(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
first, last := "Bob", "Member"
|
||||
|
||||
// first + last.
|
||||
assert.Equal(t, "Bob Member", openRouterFullName(openRouterMember{FirstName: &first, LastName: &last}, "bob@example.com"))
|
||||
// last_name null → first name alone.
|
||||
assert.Equal(t, "Bob", openRouterFullName(openRouterMember{FirstName: &first}, "bob@example.com"))
|
||||
// first_name null → last name alone.
|
||||
assert.Equal(t, "Member", openRouterFullName(openRouterMember{LastName: &last}, "bob@example.com"))
|
||||
// both null → email fallback.
|
||||
assert.Equal(t, "carol@example.com", openRouterFullName(openRouterMember{}, "carol@example.com"))
|
||||
}
|
||||
254
pkg/accessreview/drivers/pylon.go
Normal file
254
pkg/accessreview/drivers/pylon.go
Normal file
@@ -0,0 +1,254 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package drivers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"go.probo.inc/probo/pkg/coredata"
|
||||
)
|
||||
|
||||
const (
|
||||
pylonUsersEndpoint = "https://api.usepylon.com/users"
|
||||
pylonUserRolesEndpoint = "https://api.usepylon.com/user-roles"
|
||||
// pylonPageSize is the page size requested from the cursor-paginated
|
||||
// list endpoints. Pylon caps `limit` at 999 (it must be > 0 and < 1000);
|
||||
// 100 returns every member of typical organizations in one page.
|
||||
pylonPageSize = 100
|
||||
)
|
||||
|
||||
// PylonDriver lists the users (agents) of a single Pylon organization. The
|
||||
// API token (Bearer) is bound to one organization, so GET /users returns
|
||||
// every member of that organization with no tenant selector. Each user
|
||||
// carries an opaque role_id, which the driver resolves to a human-readable
|
||||
// role name via GET /user-roles.
|
||||
type PylonDriver struct {
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
var _ Driver = (*PylonDriver)(nil)
|
||||
|
||||
type pylonUser struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name"`
|
||||
RoleID string `json:"role_id"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
type pylonRole struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Slug string `json:"slug"`
|
||||
}
|
||||
|
||||
type pylonPagination struct {
|
||||
Cursor string `json:"cursor"`
|
||||
HasNextPage bool `json:"has_next_page"`
|
||||
}
|
||||
|
||||
type pylonUsersResponse struct {
|
||||
Data []pylonUser `json:"data"`
|
||||
Pagination pylonPagination `json:"pagination"`
|
||||
}
|
||||
|
||||
type pylonRolesResponse struct {
|
||||
Data []pylonRole `json:"data"`
|
||||
Pagination pylonPagination `json:"pagination"`
|
||||
}
|
||||
|
||||
func NewPylonDriver(httpClient *http.Client) *PylonDriver {
|
||||
return &PylonDriver{httpClient: httpClient}
|
||||
}
|
||||
|
||||
func (d *PylonDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error) {
|
||||
roles, err := d.fetchRoles(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var (
|
||||
records []AccountRecord
|
||||
cursor string
|
||||
)
|
||||
|
||||
for range maxPaginationPages {
|
||||
resp, err := d.fetchUsersPage(ctx, cursor)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, u := range resp.Data {
|
||||
email := strings.TrimSpace(u.Email)
|
||||
if email == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
role := roles[u.RoleID]
|
||||
|
||||
records = append(records, AccountRecord{
|
||||
Email: email,
|
||||
FullName: pylonFullName(u, email),
|
||||
Roles: pylonRoles(role),
|
||||
Active: activeFromStatus(u.Status),
|
||||
IsAdmin: pylonIsAdmin(role),
|
||||
MFAStatus: coredata.MFAStatusUnknown,
|
||||
AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown,
|
||||
AccountType: coredata.AccessReviewEntryAccountTypeUser,
|
||||
ExternalID: strings.TrimSpace(u.ID),
|
||||
})
|
||||
}
|
||||
|
||||
if !resp.Pagination.HasNextPage || resp.Pagination.Cursor == "" {
|
||||
return records, nil
|
||||
}
|
||||
|
||||
cursor = resp.Pagination.Cursor
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("cannot list all pylon users: %w", ErrPaginationLimitReached)
|
||||
}
|
||||
|
||||
// fetchRoles loads the organization's role catalogue once, keyed by role ID,
|
||||
// so each user's opaque role_id can be resolved to a role name and admin
|
||||
// classification.
|
||||
func (d *PylonDriver) fetchRoles(ctx context.Context) (map[string]pylonRole, error) {
|
||||
roles := make(map[string]pylonRole)
|
||||
|
||||
cursor := ""
|
||||
|
||||
for range maxPaginationPages {
|
||||
resp, err := d.fetchRolesPage(ctx, cursor)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, r := range resp.Data {
|
||||
roles[r.ID] = r
|
||||
}
|
||||
|
||||
if !resp.Pagination.HasNextPage || resp.Pagination.Cursor == "" {
|
||||
return roles, nil
|
||||
}
|
||||
|
||||
cursor = resp.Pagination.Cursor
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("cannot list all pylon user-roles: %w", ErrPaginationLimitReached)
|
||||
}
|
||||
|
||||
func (d *PylonDriver) fetchUsersPage(ctx context.Context, cursor string) (*pylonUsersResponse, error) {
|
||||
httpResp, err := d.fetchPage(ctx, pylonUsersEndpoint, cursor)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
defer func() {
|
||||
_ = httpResp.Body.Close()
|
||||
}()
|
||||
|
||||
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("cannot fetch pylon users: unexpected status %d", httpResp.StatusCode)
|
||||
}
|
||||
|
||||
var resp pylonUsersResponse
|
||||
if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil {
|
||||
return nil, fmt.Errorf("cannot decode pylon users response: %w", err)
|
||||
}
|
||||
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
func (d *PylonDriver) fetchRolesPage(ctx context.Context, cursor string) (*pylonRolesResponse, error) {
|
||||
httpResp, err := d.fetchPage(ctx, pylonUserRolesEndpoint, cursor)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
defer func() {
|
||||
_ = httpResp.Body.Close()
|
||||
}()
|
||||
|
||||
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("cannot fetch pylon user-roles: unexpected status %d", httpResp.StatusCode)
|
||||
}
|
||||
|
||||
var resp pylonRolesResponse
|
||||
if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil {
|
||||
return nil, fmt.Errorf("cannot decode pylon user-roles response: %w", err)
|
||||
}
|
||||
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
func (d *PylonDriver) fetchPage(ctx context.Context, endpoint, cursor string) (*http.Response, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot create pylon request: %w", err)
|
||||
}
|
||||
|
||||
q := req.URL.Query()
|
||||
q.Set("limit", strconv.Itoa(pylonPageSize))
|
||||
|
||||
if cursor != "" {
|
||||
q.Set("cursor", cursor)
|
||||
}
|
||||
|
||||
req.URL.RawQuery = q.Encode()
|
||||
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
httpResp, err := d.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot execute pylon request: %w", err)
|
||||
}
|
||||
|
||||
return httpResp, nil
|
||||
}
|
||||
|
||||
func pylonFullName(u pylonUser, fallback string) string {
|
||||
if name := strings.TrimSpace(u.Name); name != "" {
|
||||
return name
|
||||
}
|
||||
|
||||
return fallback
|
||||
}
|
||||
|
||||
// pylonRoles returns the user's role as a single-element slice using the
|
||||
// resolved role name, or an empty slice when the role_id did not resolve.
|
||||
func pylonRoles(role pylonRole) []string {
|
||||
if name := strings.TrimSpace(role.Name); name != "" {
|
||||
return []string{name}
|
||||
}
|
||||
|
||||
return []string{}
|
||||
}
|
||||
|
||||
// pylonIsAdmin reports whether the resolved role is Pylon's built-in Admin
|
||||
// role. Pylon ships two default roles (Member and Admin); the match is on
|
||||
// the stable slug, falling back to an exact (case-insensitive) name match,
|
||||
// so a custom role merely containing "admin" is not auto-classified.
|
||||
func pylonIsAdmin(role pylonRole) bool {
|
||||
if slug := strings.TrimSpace(role.Slug); slug != "" {
|
||||
return strings.EqualFold(slug, "admin")
|
||||
}
|
||||
|
||||
return strings.EqualFold(strings.TrimSpace(role.Name), "Admin")
|
||||
}
|
||||
76
pkg/accessreview/drivers/pylon_test.go
Normal file
76
pkg/accessreview/drivers/pylon_test.go
Normal file
@@ -0,0 +1,76 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package drivers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.probo.inc/probo/pkg/coredata"
|
||||
)
|
||||
|
||||
func TestPylonDriver(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rec := newRecorder(t, "testdata/pylon", "PYLON_API_KEY")
|
||||
client := newVCRClient(rec, bearerAuth(os.Getenv("PYLON_API_KEY")))
|
||||
|
||||
driver := NewPylonDriver(client)
|
||||
records, err := driver.ListAccounts(context.Background())
|
||||
require.NoError(t, err)
|
||||
require.Len(t, records, 3)
|
||||
|
||||
admin := records[0]
|
||||
assert.Equal(t, "user_1", admin.ExternalID)
|
||||
assert.Equal(t, "alice@example.com", admin.Email)
|
||||
assert.Equal(t, "Alice Admin", admin.FullName)
|
||||
// role_id "role_admin" resolved through GET /user-roles.
|
||||
assert.Equal(t, []string{"Admin"}, admin.Roles)
|
||||
assert.True(t, admin.IsAdmin)
|
||||
require.NotNil(t, admin.Active)
|
||||
assert.True(t, *admin.Active)
|
||||
assert.Equal(t, coredata.AccessReviewEntryAccountTypeUser, admin.AccountType)
|
||||
|
||||
member := records[1]
|
||||
assert.Equal(t, []string{"Member"}, member.Roles)
|
||||
assert.False(t, member.IsAdmin)
|
||||
|
||||
// No name → display name falls back to the email; "deactivated" status →
|
||||
// Active false.
|
||||
deactivated := records[2]
|
||||
assert.Equal(t, "carol@example.com", deactivated.FullName)
|
||||
assert.Equal(t, []string{"Member"}, deactivated.Roles)
|
||||
require.NotNil(t, deactivated.Active)
|
||||
assert.False(t, *deactivated.Active)
|
||||
}
|
||||
|
||||
func TestPylonIsAdmin(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// The stable slug is preferred when present.
|
||||
assert.True(t, pylonIsAdmin(pylonRole{Slug: "admin", Name: "Admin"}))
|
||||
assert.False(t, pylonIsAdmin(pylonRole{Slug: "member", Name: "Member"}))
|
||||
// A custom role named like an admin but with a non-admin slug is NOT an
|
||||
// admin — the slug wins.
|
||||
assert.False(t, pylonIsAdmin(pylonRole{Slug: "billing", Name: "Billing Admin"}))
|
||||
// With no slug, the exact (case-insensitive) name is used.
|
||||
assert.True(t, pylonIsAdmin(pylonRole{Name: "Admin"}))
|
||||
assert.False(t, pylonIsAdmin(pylonRole{Name: "Billing Admin"}))
|
||||
// An unresolved role (zero value: role_id not in the catalogue) is not admin.
|
||||
assert.False(t, pylonIsAdmin(pylonRole{}))
|
||||
}
|
||||
35
pkg/accessreview/drivers/testdata/brevo.yaml
vendored
Normal file
35
pkg/accessreview/drivers/testdata/brevo.yaml
vendored
Normal file
@@ -0,0 +1,35 @@
|
||||
---
|
||||
# Recorded live against GET /v3/organization/invited/users (Brevo, api-key
|
||||
# header) on 2026-06-24, then anonymized: real ids, emails and locales are
|
||||
# replaced with synthetic values, while the {users} wrapper and member shape
|
||||
# are the verbatim live response — note is_owner is a JSON boolean (not the
|
||||
# string the docs/SDK show), an `id` is present, and feature_access carries
|
||||
# more keys than documented (transactional/phone/meetings/sequences) with a
|
||||
# "full" level. The api-key header is stripped on save.
|
||||
version: 2
|
||||
interactions:
|
||||
- id: 0
|
||||
request:
|
||||
proto: HTTP/1.1
|
||||
proto_major: 1
|
||||
proto_minor: 1
|
||||
content_length: 0
|
||||
host: api.brevo.com
|
||||
headers:
|
||||
Accept:
|
||||
- application/json
|
||||
url: https://api.brevo.com/v3/organization/invited/users
|
||||
method: GET
|
||||
response:
|
||||
proto: HTTP/2.0
|
||||
proto_major: 2
|
||||
proto_minor: 0
|
||||
content_length: -1
|
||||
uncompressed: true
|
||||
body: '{"users":[{"id":"000000000000000000000001","email":"owner@example.com","is_owner":true,"status":"active","feature_access":{"marketing":"owner","conversations":"owner","crm":"owner","transactional":"owner","phone":"owner"},"locale":"fr_FR"},{"id":"000000000000000000000002","email":"member@example.com","is_owner":false,"status":"active","feature_access":{"marketing":"none","conversations":"none","crm":"full","transactional":"full","phone":"none","meetings":"none","sequences":"none"},"locale":"fr_FR"},{"id":"000000000000000000000003","email":"viewer@example.com","is_owner":false,"status":"active","feature_access":{"marketing":"none","conversations":"none","crm":"full","transactional":"full","phone":"none","meetings":"none","sequences":"none"},"locale":"en_US"}]}'
|
||||
headers:
|
||||
Content-Type:
|
||||
- application/json
|
||||
status: 200 OK
|
||||
code: 200
|
||||
duration: 124ms
|
||||
71
pkg/accessreview/drivers/testdata/incidentio.yaml
vendored
Normal file
71
pkg/accessreview/drivers/testdata/incidentio.yaml
vendored
Normal file
@@ -0,0 +1,71 @@
|
||||
---
|
||||
# Hand-authored fixture for GET /v2/users against an incident.io
|
||||
# organization. The user object shape (id, name, email, role, base_role,
|
||||
# custom_roles) and the {users, pagination_meta} wrapper mirror the
|
||||
# documented response. Synthetic IDs/emails only.
|
||||
#
|
||||
# Two interactions deliberately split three users across two pages, where the
|
||||
# FIRST page returns fewer than page_size rows while still handing back a
|
||||
# non-empty `after` cursor. This regression-guards the pagination terminator:
|
||||
# the driver must follow the cursor (and return all three users) rather than
|
||||
# stop early on the short first page.
|
||||
version: 2
|
||||
interactions:
|
||||
- id: 0
|
||||
request:
|
||||
proto: HTTP/1.1
|
||||
proto_major: 1
|
||||
proto_minor: 1
|
||||
content_length: 0
|
||||
host: api.incident.io
|
||||
form:
|
||||
page_size:
|
||||
- "100"
|
||||
headers:
|
||||
Accept:
|
||||
- application/json
|
||||
url: https://api.incident.io/v2/users?page_size=100
|
||||
method: GET
|
||||
response:
|
||||
proto: HTTP/2.0
|
||||
proto_major: 2
|
||||
proto_minor: 0
|
||||
content_length: -1
|
||||
uncompressed: true
|
||||
body: '{"users":[{"id":"01ABCOWNER","name":"Lisa Curtis","email":"lisa@example.com","role":"viewer","base_role":{"id":"r1","name":"Owner","slug":"owner"},"custom_roles":[],"slack_user_id":"U01"},{"id":"01DEFRESP","name":"Sam Responder","email":"sam@example.com","role":"responder","base_role":{"id":"r2","name":"Responder","slug":"responder"},"custom_roles":[{"id":"c1","name":"On-call Lead","slug":"on-call-lead"}],"slack_user_id":"U02"}],"pagination_meta":{"after":"01PAGE2CURSOR0000000000000","page_size":100,"total_record_count":3}}'
|
||||
headers:
|
||||
Content-Type:
|
||||
- application/json
|
||||
status: 200 OK
|
||||
code: 200
|
||||
duration: 150ms
|
||||
- id: 1
|
||||
request:
|
||||
proto: HTTP/1.1
|
||||
proto_major: 1
|
||||
proto_minor: 1
|
||||
content_length: 0
|
||||
host: api.incident.io
|
||||
form:
|
||||
after:
|
||||
- "01PAGE2CURSOR0000000000000"
|
||||
page_size:
|
||||
- "100"
|
||||
headers:
|
||||
Accept:
|
||||
- application/json
|
||||
url: https://api.incident.io/v2/users?after=01PAGE2CURSOR0000000000000&page_size=100
|
||||
method: GET
|
||||
response:
|
||||
proto: HTTP/2.0
|
||||
proto_major: 2
|
||||
proto_minor: 0
|
||||
content_length: -1
|
||||
uncompressed: true
|
||||
body: '{"users":[{"id":"01GHIADMIN","name":"","email":"legacy-admin@example.com","role":"administrator","base_role":null,"custom_roles":[],"slack_user_id":""}],"pagination_meta":{"after":"","page_size":100,"total_record_count":3}}'
|
||||
headers:
|
||||
Content-Type:
|
||||
- application/json
|
||||
status: 200 OK
|
||||
code: 200
|
||||
duration: 140ms
|
||||
40
pkg/accessreview/drivers/testdata/openrouter.yaml
vendored
Normal file
40
pkg/accessreview/drivers/testdata/openrouter.yaml
vendored
Normal file
@@ -0,0 +1,40 @@
|
||||
---
|
||||
# Recorded live against GET /api/v1/organization/members (with an OpenRouter
|
||||
# organization management key) on 2026-06-24, then anonymized: the real
|
||||
# member's id, name and email are replaced with synthetic values, while the
|
||||
# {data, total_count} wrapper and member shape (id, first_name, last_name,
|
||||
# email, role) are the verbatim live response shape. The Authorization header
|
||||
# is stripped on save. The org:member role and null-name fallbacks (absent
|
||||
# from this single-admin org) are covered by unit tests in the driver test.
|
||||
version: 2
|
||||
interactions:
|
||||
- id: 0
|
||||
request:
|
||||
proto: HTTP/1.1
|
||||
proto_major: 1
|
||||
proto_minor: 1
|
||||
content_length: 0
|
||||
host: openrouter.ai
|
||||
form:
|
||||
limit:
|
||||
- "100"
|
||||
offset:
|
||||
- "0"
|
||||
headers:
|
||||
Accept:
|
||||
- application/json
|
||||
url: https://openrouter.ai/api/v1/organization/members?limit=100&offset=0
|
||||
method: GET
|
||||
response:
|
||||
proto: HTTP/2.0
|
||||
proto_major: 2
|
||||
proto_minor: 0
|
||||
content_length: -1
|
||||
uncompressed: true
|
||||
body: '{"data":[{"id":"user_000000000000000000000admin","first_name":"Ada","last_name":"Admin","email":"ada.admin@example.com","role":"org:admin"}],"total_count":1}'
|
||||
headers:
|
||||
Content-Type:
|
||||
- application/json
|
||||
status: 200 OK
|
||||
code: 200
|
||||
duration: 454ms
|
||||
64
pkg/accessreview/drivers/testdata/pylon.yaml
vendored
Normal file
64
pkg/accessreview/drivers/testdata/pylon.yaml
vendored
Normal file
@@ -0,0 +1,64 @@
|
||||
---
|
||||
# Hand-authored fixture for the Pylon access-review driver. The driver first
|
||||
# resolves the organization's role catalogue (GET /user-roles), then lists
|
||||
# members (GET /users) and maps each user's opaque role_id to a role name.
|
||||
# The user/role object shapes mirror the documented OpenAPI schema. Synthetic
|
||||
# IDs/emails only.
|
||||
version: 2
|
||||
interactions:
|
||||
- id: 0
|
||||
request:
|
||||
proto: HTTP/1.1
|
||||
proto_major: 1
|
||||
proto_minor: 1
|
||||
content_length: 0
|
||||
host: api.usepylon.com
|
||||
form:
|
||||
limit:
|
||||
- "100"
|
||||
headers:
|
||||
Accept:
|
||||
- application/json
|
||||
url: https://api.usepylon.com/user-roles?limit=100
|
||||
method: GET
|
||||
response:
|
||||
proto: HTTP/2.0
|
||||
proto_major: 2
|
||||
proto_minor: 0
|
||||
content_length: -1
|
||||
uncompressed: true
|
||||
body: '{"data":[{"id":"role_admin","name":"Admin","slug":"admin"},{"id":"role_member","name":"Member","slug":"member"}],"pagination":{"cursor":"","has_next_page":false},"request_id":"req_roles_1"}'
|
||||
headers:
|
||||
Content-Type:
|
||||
- application/json
|
||||
status: 200 OK
|
||||
code: 200
|
||||
duration: 120ms
|
||||
- id: 1
|
||||
request:
|
||||
proto: HTTP/1.1
|
||||
proto_major: 1
|
||||
proto_minor: 1
|
||||
content_length: 0
|
||||
host: api.usepylon.com
|
||||
form:
|
||||
limit:
|
||||
- "100"
|
||||
headers:
|
||||
Accept:
|
||||
- application/json
|
||||
url: https://api.usepylon.com/users?limit=100
|
||||
method: GET
|
||||
response:
|
||||
proto: HTTP/2.0
|
||||
proto_major: 2
|
||||
proto_minor: 0
|
||||
content_length: -1
|
||||
uncompressed: true
|
||||
body: '{"data":[{"id":"user_1","email":"alice@example.com","name":"Alice Admin","role_id":"role_admin","status":"active"},{"id":"user_2","email":"bob@example.com","name":"Bob Member","role_id":"role_member","status":"active"},{"id":"user_3","email":"carol@example.com","name":"","role_id":"role_member","status":"deactivated"}],"pagination":{"cursor":"","has_next_page":false},"request_id":"req_users_1"}'
|
||||
headers:
|
||||
Content-Type:
|
||||
- application/json
|
||||
status: 200 OK
|
||||
code: 200
|
||||
duration: 140ms
|
||||
@@ -51,12 +51,13 @@ func newRecorder(t *testing.T, cassettePath string, envVar string) *recorder.Rec
|
||||
)),
|
||||
recorder.WithHook(func(i *cassette.Interaction) error {
|
||||
i.Request.Headers.Del("Authorization")
|
||||
// Providers like Anthropic (x-api-key) and SigNoz
|
||||
// (SIGNOZ-API-KEY) authenticate via a custom header rather
|
||||
// than Authorization; strip those too so a re-record never
|
||||
// persists a raw key.
|
||||
// Providers like Anthropic (x-api-key), SigNoz
|
||||
// (SIGNOZ-API-KEY) and Brevo (api-key) authenticate via a
|
||||
// custom header rather than Authorization; strip those too so a
|
||||
// re-record never persists a raw key.
|
||||
i.Request.Headers.Del("X-Api-Key")
|
||||
i.Request.Headers.Del("Signoz-Api-Key")
|
||||
i.Request.Headers.Del("Api-Key")
|
||||
|
||||
return nil
|
||||
}, recorder.BeforeSaveHook),
|
||||
|
||||
Reference in New Issue
Block a user