Add five API-key access-review connectors

Add Mercury, Apollo.io, Deepgram, ClickHouse Cloud, and Langfuse 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.

- Mercury: Bearer token, GET /api/v1/users, cursor pagination.
- Apollo.io: x-api-key header, GET /api/v1/users/search (teammates).
- Deepgram: Token scheme; lists members across every project and
  dedupes by member_id, unioning per-project scopes.
- ClickHouse Cloud: HTTP Basic (keyId:keySecret); discovers the org
  via GET /v1/organizations, then lists its members.
- Langfuse: HTTP Basic (publicKey:secretKey); a base-URL setting
  selects the regional cloud host or a self-hosted instance.

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-12 19:53:13 +02:00
parent bd6a470d6d
commit 6a285a59b9
41 changed files with 2245 additions and 1 deletions

View File

@@ -0,0 +1,196 @@
// 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 (
apolloUsersEndpoint = "https://api.apollo.io/api/v1/users/search"
apolloUsersPageSize = 100
)
// ApolloDriver lists the teammates (seats) of a single Apollo.io account.
// The master API key is bound to one account, so GET /api/v1/users/search
// returns every teammate of that account. The key is presented in the
// x-api-key header by the connection transport, not here.
type ApolloDriver struct {
httpClient *http.Client
}
var _ Driver = (*ApolloDriver)(nil)
type apolloUser struct {
ID string `json:"id"`
Name string `json:"name"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
Email string `json:"email"`
// Role is Apollo's permission-profile name (e.g. "Admin", "Billing and
// Seat Manager"). It is decoded as RawMessage and read via apolloRole so
// a non-string shape (object/null on some plans) degrades to an empty
// role instead of failing the decode of the whole page.
Role json.RawMessage `json:"role"`
}
type apolloUsersResponse struct {
Users []apolloUser `json:"users"`
Pagination struct {
Page int `json:"page"`
PerPage int `json:"per_page"`
TotalPages int `json:"total_pages"`
TotalEntries int `json:"total_entries"`
} `json:"pagination"`
}
func NewApolloDriver(httpClient *http.Client) *ApolloDriver {
return &ApolloDriver{httpClient: httpClient}
}
func (d *ApolloDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error) {
var records []AccountRecord
for page := 1; page <= maxPaginationPages; page++ {
resp, err := d.fetchUsersPage(ctx, page)
if err != nil {
return nil, err
}
for _, u := range resp.Users {
email := strings.TrimSpace(u.Email)
if email == "" {
continue
}
role := apolloRole(u.Role)
records = append(records, AccountRecord{
Email: email,
FullName: apolloFullName(u, email),
Roles: apolloRoles(role),
IsAdmin: apolloIsAdmin(role),
MFAStatus: coredata.MFAStatusUnknown,
AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown,
AccountType: coredata.AccessReviewEntryAccountTypeUser,
ExternalID: strings.TrimSpace(u.ID),
})
}
if resp.Pagination.TotalPages <= page || len(resp.Users) == 0 {
return records, nil
}
}
return nil, fmt.Errorf("cannot list all apollo users: %w", ErrPaginationLimitReached)
}
func (d *ApolloDriver) fetchUsersPage(ctx context.Context, page int) (*apolloUsersResponse, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, apolloUsersEndpoint, nil)
if err != nil {
return nil, fmt.Errorf("cannot create apollo users request: %w", err)
}
q := req.URL.Query()
q.Set("page", strconv.Itoa(page))
q.Set("per_page", strconv.Itoa(apolloUsersPageSize))
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 apollo users request: %w", err)
}
defer func() {
_ = httpResp.Body.Close()
}()
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
return nil, fmt.Errorf("cannot fetch apollo users: unexpected status %d", httpResp.StatusCode)
}
var resp apolloUsersResponse
if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil {
return nil, fmt.Errorf("cannot decode apollo users response: %w", err)
}
return &resp, nil
}
func apolloFullName(u apolloUser, fallback string) string {
if name := strings.TrimSpace(u.Name); name != "" {
return name
}
combined := strings.TrimSpace(strings.TrimSpace(u.FirstName) + " " + strings.TrimSpace(u.LastName))
if combined != "" {
return combined
}
return fallback
}
// apolloRole extracts a role label from Apollo's `role` field, which is
// normally a plain string. It also tolerates a `{"name": ...}` object and a
// null/absent value so an unexpected shape on some plans yields an empty
// role rather than failing the whole page decode.
func apolloRole(raw json.RawMessage) string {
if len(raw) == 0 {
return ""
}
var s string
if err := json.Unmarshal(raw, &s); err == nil {
return strings.TrimSpace(s)
}
var obj struct {
Name string `json:"name"`
}
if err := json.Unmarshal(raw, &obj); err == nil {
return strings.TrimSpace(obj.Name)
}
return ""
}
// apolloRoles wraps a single Apollo permission-profile name as the account's
// role set, returning an empty slice when no profile is present.
func apolloRoles(role string) []string {
if role == "" {
return []string{}
}
return []string{role}
}
// apolloIsAdmin reports whether a permission-profile name is Apollo's
// built-in super-admin profile ("Admin"). Apollo exposes no boolean admin
// flag and lets customers name custom profiles freely, so the match is exact
// (case-insensitive), not a substring: a profile merely containing "admin"
// (e.g. "Billing Admin") is not auto-classified — its Role is still surfaced
// for the reviewer to judge.
func apolloIsAdmin(role string) bool {
return strings.EqualFold(strings.TrimSpace(role), "Admin")
}

View File

@@ -0,0 +1,70 @@
// 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 TestApolloDriver(t *testing.T) {
t.Parallel()
rec := newRecorder(t, "testdata/apollo", "APOLLO_API_KEY")
// Apollo authenticates via the x-api-key header, not Authorization.
client := newVCRClientWithHeader(rec, "x-api-key", os.Getenv("APOLLO_API_KEY"))
driver := NewApolloDriver(client)
records, err := driver.ListAccounts(context.Background())
require.NoError(t, err)
require.Len(t, records, 4)
admin := records[0]
assert.Equal(t, "5f0000000000000000000001", admin.ExternalID)
assert.Equal(t, "alice@example.com", admin.Email)
assert.Equal(t, "Alice Admin", admin.FullName)
assert.Equal(t, []string{"Admin"}, admin.Roles)
assert.True(t, admin.IsAdmin)
assert.Equal(t, coredata.AccessReviewEntryAccountTypeUser, admin.AccountType)
rep := records[1]
assert.Equal(t, []string{"Sales Rep"}, rep.Roles)
assert.False(t, rep.IsAdmin)
manager := records[2]
assert.Equal(t, []string{"Billing and Seat Manager"}, manager.Roles)
assert.False(t, manager.IsAdmin)
// No name and no first/last: the display name falls back to the email.
noName := records[3]
assert.Equal(t, "dave@example.com", noName.Email)
assert.Equal(t, "dave@example.com", noName.FullName)
}
func TestApolloIsAdmin(t *testing.T) {
t.Parallel()
assert.True(t, apolloIsAdmin("Admin"))
assert.True(t, apolloIsAdmin("admin"))
// Exact match only: profiles that merely contain "admin" are not admins.
assert.False(t, apolloIsAdmin("Master Admin"))
assert.False(t, apolloIsAdmin("Billing Admin"))
assert.False(t, apolloIsAdmin("Sales Rep"))
}

View File

@@ -0,0 +1,224 @@
// 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"
"net/url"
"strings"
"time"
"go.probo.inc/probo/pkg/coredata"
)
const clickhouseAPIBaseURL = "https://api.clickhouse.cloud"
// ClickHouseDriver lists the members of a single ClickHouse Cloud
// organization. A key/secret pair (HTTP Basic) is scoped to exactly one
// organization, so the driver discovers that organization via
// GET /v1/organizations and then lists its members — no org ID needs to be
// configured. The Basic credential is applied by the connection transport.
type ClickHouseDriver struct {
httpClient *http.Client
}
var _ Driver = (*ClickHouseDriver)(nil)
type clickhouseOrgsResponse struct {
Result []struct {
ID string `json:"id"`
Name string `json:"name"`
} `json:"result"`
}
type clickhouseMembersResponse struct {
Result []clickhouseMember `json:"result"`
}
type clickhouseMember struct {
UserID string `json:"userId"`
Name string `json:"name"`
Email string `json:"email"`
Role string `json:"role"`
JoinedAt string `json:"joinedAt"`
AssignedRoles []struct {
RoleName string `json:"roleName"`
} `json:"assignedRoles"`
}
func NewClickHouseDriver(httpClient *http.Client) *ClickHouseDriver {
return &ClickHouseDriver{httpClient: httpClient}
}
func (d *ClickHouseDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error) {
organizationID, err := d.resolveOrganizationID(ctx)
if err != nil {
return nil, err
}
members, err := d.fetchMembers(ctx, organizationID)
if err != nil {
return nil, err
}
records := make([]AccountRecord, 0, len(members))
for _, m := range members {
email := strings.TrimSpace(m.Email)
if email == "" {
continue
}
record := AccountRecord{
Email: email,
FullName: clickhouseFullName(m, email),
Roles: clickhouseRoles(m),
IsAdmin: clickhouseIsAdmin(m),
MFAStatus: coredata.MFAStatusUnknown,
AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown,
AccountType: coredata.AccessReviewEntryAccountTypeUser,
ExternalID: strings.TrimSpace(m.UserID),
}
if m.JoinedAt != "" {
if t, err := time.Parse(time.RFC3339, m.JoinedAt); err == nil {
record.CreatedAt = &t
}
}
records = append(records, record)
}
return records, nil
}
func (d *ClickHouseDriver) resolveOrganizationID(ctx context.Context) (string, error) {
endpoint, err := url.JoinPath(clickhouseAPIBaseURL, "v1", "organizations")
if err != nil {
return "", fmt.Errorf("cannot build clickhouse organizations URL: %w", err)
}
var resp clickhouseOrgsResponse
if err := d.getJSON(ctx, endpoint, "clickhouse organizations", &resp); err != nil {
return "", err
}
if len(resp.Result) == 0 || strings.TrimSpace(resp.Result[0].ID) == "" {
return "", fmt.Errorf("cannot determine clickhouse organization: API key is not associated with any organization")
}
return strings.TrimSpace(resp.Result[0].ID), nil
}
func (d *ClickHouseDriver) fetchMembers(ctx context.Context, organizationID string) ([]clickhouseMember, error) {
endpoint, err := url.JoinPath(clickhouseAPIBaseURL, "v1", "organizations", url.PathEscape(organizationID), "members")
if err != nil {
return nil, fmt.Errorf("cannot build clickhouse members URL: %w", err)
}
var resp clickhouseMembersResponse
if err := d.getJSON(ctx, endpoint, "clickhouse members", &resp); err != nil {
return nil, err
}
return resp.Result, nil
}
func (d *ClickHouseDriver) getJSON(ctx context.Context, endpoint, what string, out any) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return fmt.Errorf("cannot create %s request: %w", what, err)
}
req.Header.Set("Accept", "application/json")
httpResp, err := d.httpClient.Do(req)
if err != nil {
return fmt.Errorf("cannot execute %s request: %w", what, err)
}
defer func() {
_ = httpResp.Body.Close()
}()
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
return fmt.Errorf("cannot fetch %s: unexpected status %d", what, httpResp.StatusCode)
}
if err := json.NewDecoder(httpResp.Body).Decode(out); err != nil {
return fmt.Errorf("cannot decode %s response: %w", what, err)
}
return nil
}
func clickhouseFullName(m clickhouseMember, fallback string) string {
if name := strings.TrimSpace(m.Name); name != "" {
return name
}
return fallback
}
// clickhouseRoles prefers the custom/system roles in assignedRoles (the live
// source of truth) and falls back to the deprecated `role` field, which is
// frozen for organizations migrated to custom roles.
func clickhouseRoles(m clickhouseMember) []string {
names := make([]string, 0, len(m.AssignedRoles))
for _, r := range m.AssignedRoles {
if name := strings.TrimSpace(r.RoleName); name != "" {
names = append(names, name)
}
}
if len(names) > 0 {
return names
}
switch strings.ToLower(strings.TrimSpace(m.Role)) {
case "admin":
return []string{"Admin"}
case "developer":
return []string{"Developer"}
default:
if r := strings.TrimSpace(m.Role); r != "" {
return []string{r}
}
return []string{}
}
}
func clickhouseIsAdmin(m clickhouseMember) bool {
// assignedRoles is the live source of truth. The deprecated role field is
// frozen at its pre-migration value for organizations that moved to custom
// roles, so consult it only when there are no assigned roles — otherwise a
// stale "admin" could misclassify a since-demoted member.
if len(m.AssignedRoles) > 0 {
for _, r := range m.AssignedRoles {
if strings.EqualFold(strings.TrimSpace(r.RoleName), "admin") {
return true
}
}
return false
}
return strings.EqualFold(strings.TrimSpace(m.Role), "admin")
}

View File

@@ -0,0 +1,59 @@
// 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 TestClickHouseDriver(t *testing.T) {
t.Parallel()
rec := newRecorder(t, "testdata/clickhouse", "CLICKHOUSE_API_KEY")
// ClickHouse Cloud authenticates with HTTP Basic (keyId:keySecret). The
// matcher ignores Authorization, so replay needs no auth.
client := newVCRClient(rec, basicAuthUserPass(os.Getenv("CLICKHOUSE_API_KEY")))
driver := NewClickHouseDriver(client)
records, err := driver.ListAccounts(context.Background())
require.NoError(t, err)
require.Len(t, records, 3)
admin := records[0]
assert.Equal(t, "u-0000-0001", admin.ExternalID)
assert.Equal(t, "admin@example.com", admin.Email)
assert.Equal(t, "Admin User", admin.FullName)
assert.Equal(t, []string{"Admin"}, admin.Roles)
assert.True(t, admin.IsAdmin)
assert.NotNil(t, admin.CreatedAt)
assert.Equal(t, coredata.AccessReviewEntryAccountTypeUser, admin.AccountType)
dev := records[1]
assert.Equal(t, []string{"Developer"}, dev.Roles)
assert.False(t, dev.IsAdmin)
// assignedRoles wins over the deprecated role field; a custom role
// merely containing "Admin" is not promoted to admin.
custom := records[2]
assert.Equal(t, "custom@example.com", custom.FullName)
assert.Equal(t, []string{"Billing Admin"}, custom.Roles)
assert.False(t, custom.IsAdmin)
}

View File

@@ -0,0 +1,244 @@
// 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"
"net/url"
"strings"
"go.probo.inc/probo/pkg/coredata"
)
const deepgramAPIBaseURL = "https://api.deepgram.com"
// DeepgramDriver lists the members of every Deepgram project the API key
// can access. The key (presented in the `Authorization: Token <key>`
// scheme by the connection transport) is scoped to a single account, whose
// members may span several projects; the driver aggregates them and dedupes
// by member_id, unioning each member's per-project scopes.
type DeepgramDriver struct {
httpClient *http.Client
}
var _ Driver = (*DeepgramDriver)(nil)
type deepgramProject struct {
ProjectID string `json:"project_id"`
Name string `json:"name"`
}
type deepgramProjectsResponse struct {
Projects []deepgramProject `json:"projects"`
}
type deepgramMember struct {
MemberID string `json:"member_id"`
Email string `json:"email"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
Scopes []string `json:"scopes"`
}
type deepgramMembersResponse struct {
Members []deepgramMember `json:"members"`
}
func NewDeepgramDriver(httpClient *http.Client) *DeepgramDriver {
return &DeepgramDriver{httpClient: httpClient}
}
func (d *DeepgramDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error) {
projects, err := d.fetchProjects(ctx)
if err != nil {
return nil, err
}
// Aggregate members across projects, preserving first-seen order and
// unioning the scopes a member holds in each project.
order := make([]string, 0)
merged := make(map[string]*deepgramMember)
for _, project := range projects {
members, err := d.fetchProjectMembers(ctx, project.ProjectID)
if err != nil {
return nil, err
}
for _, m := range members {
existing, ok := merged[m.MemberID]
if !ok {
copied := m
merged[m.MemberID] = &copied
order = append(order, m.MemberID)
continue
}
existing.Scopes = deepgramUnionScopes(existing.Scopes, m.Scopes)
}
}
records := make([]AccountRecord, 0, len(order))
for _, id := range order {
m := merged[id]
email := strings.TrimSpace(m.Email)
if email == "" {
continue
}
records = append(records, AccountRecord{
Email: email,
FullName: deepgramFullName(*m, email),
Roles: deepgramRoles(m.Scopes),
IsAdmin: deepgramIsAdmin(m.Scopes),
MFAStatus: coredata.MFAStatusUnknown,
AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown,
AccountType: coredata.AccessReviewEntryAccountTypeUser,
ExternalID: strings.TrimSpace(m.MemberID),
})
}
return records, nil
}
func (d *DeepgramDriver) fetchProjects(ctx context.Context) ([]deepgramProject, error) {
endpoint, err := url.JoinPath(deepgramAPIBaseURL, "v1", "projects")
if err != nil {
return nil, fmt.Errorf("cannot build deepgram projects URL: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, fmt.Errorf("cannot create deepgram projects request: %w", err)
}
req.Header.Set("Accept", "application/json")
httpResp, err := d.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("cannot execute deepgram projects request: %w", err)
}
defer func() {
_ = httpResp.Body.Close()
}()
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
return nil, fmt.Errorf("cannot fetch deepgram projects: unexpected status %d", httpResp.StatusCode)
}
var resp deepgramProjectsResponse
if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil {
return nil, fmt.Errorf("cannot decode deepgram projects response: %w", err)
}
return resp.Projects, nil
}
func (d *DeepgramDriver) fetchProjectMembers(ctx context.Context, projectID string) ([]deepgramMember, error) {
endpoint, err := url.JoinPath(deepgramAPIBaseURL, "v1", "projects", url.PathEscape(projectID), "members")
if err != nil {
return nil, fmt.Errorf("cannot build deepgram members URL: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, fmt.Errorf("cannot create deepgram members request: %w", err)
}
req.Header.Set("Accept", "application/json")
httpResp, err := d.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("cannot execute deepgram members request: %w", err)
}
defer func() {
_ = httpResp.Body.Close()
}()
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
return nil, fmt.Errorf("cannot fetch deepgram members: unexpected status %d", httpResp.StatusCode)
}
var resp deepgramMembersResponse
if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil {
return nil, fmt.Errorf("cannot decode deepgram members response: %w", err)
}
return resp.Members, nil
}
func deepgramFullName(m deepgramMember, fallback string) string {
fullName := strings.TrimSpace(strings.TrimSpace(m.FirstName) + " " + strings.TrimSpace(m.LastName))
if fullName != "" {
return fullName
}
return fallback
}
func deepgramUnionScopes(a, b []string) []string {
seen := make(map[string]bool, len(a))
out := make([]string, 0, len(a)+len(b))
for _, scopes := range [][]string{a, b} {
for _, s := range scopes {
if seen[s] {
continue
}
seen[s] = true
out = append(out, s)
}
}
return out
}
// deepgramRoles derives a role label from a member's scopes. Deepgram has no
// dedicated role field; ownership/administration is expressed through the
// scope list. Unknown scope sets fall back to "Member".
func deepgramRoles(scopes []string) []string {
switch {
case deepgramHasScope(scopes, "owner"):
return []string{"Owner"}
case deepgramHasScope(scopes, "admin"):
return []string{"Admin"}
default:
return []string{"Member"}
}
}
func deepgramIsAdmin(scopes []string) bool {
return deepgramHasScope(scopes, "owner") || deepgramHasScope(scopes, "admin")
}
func deepgramHasScope(scopes []string, want string) bool {
for _, s := range scopes {
if strings.EqualFold(strings.TrimSpace(s), want) {
return true
}
}
return false
}

View File

@@ -0,0 +1,85 @@
// 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 TestDeepgramDriver(t *testing.T) {
t.Parallel()
rec := newRecorder(t, "testdata/deepgram", "DEEPGRAM_API_KEY")
// Deepgram authenticates with the `Token` scheme. The matcher ignores
// Authorization, so replay needs no auth; the value matters only when
// re-recording.
auth := ""
if token := os.Getenv("DEEPGRAM_API_KEY"); token != "" {
auth = "Token " + token
}
client := newVCRClient(rec, auth)
driver := NewDeepgramDriver(client)
records, err := driver.ListAccounts(context.Background())
require.NoError(t, err)
// owner@example.com appears in both projects and must be deduped.
require.Len(t, records, 3)
// owner@example.com is first seen with only ["member"] in project-1 and
// gains ["owner"] in project-2. The Owner role / admin flag therefore
// depend on the cross-project scope union actually taking effect.
owner := records[0]
assert.Equal(t, "m-0000-0000-0001", owner.ExternalID)
assert.Equal(t, "owner@example.com", owner.Email)
assert.Equal(t, "Olivia Owner", owner.FullName)
assert.Equal(t, []string{"Owner"}, owner.Roles)
assert.True(t, owner.IsAdmin)
assert.Equal(t, coredata.AccessReviewEntryAccountTypeUser, owner.AccountType)
member := records[1]
assert.Equal(t, "member@example.com", member.Email)
assert.Equal(t, []string{"Member"}, member.Roles)
assert.False(t, member.IsAdmin)
dev := records[2]
assert.Equal(t, "dev@example.com", dev.Email)
assert.Equal(t, []string{"Member"}, dev.Roles)
}
func TestDeepgramRoles(t *testing.T) {
t.Parallel()
assert.Equal(t, []string{"Owner"}, deepgramRoles([]string{"owner"}))
assert.Equal(t, []string{"Admin"}, deepgramRoles([]string{"admin", "read:transcripts"}))
assert.Equal(t, []string{"Member"}, deepgramRoles([]string{"read:transcripts"}))
assert.True(t, deepgramIsAdmin([]string{"owner"}))
assert.True(t, deepgramIsAdmin([]string{"admin"}))
assert.False(t, deepgramIsAdmin([]string{"member"}))
}
func TestDeepgramUnionScopes(t *testing.T) {
t.Parallel()
assert.Equal(t, []string{"member", "owner"}, deepgramUnionScopes([]string{"member"}, []string{"owner"}))
assert.Equal(t, []string{"a", "b"}, deepgramUnionScopes([]string{"a", "b"}, []string{"a"}))
assert.Empty(t, deepgramUnionScopes(nil, nil))
}

View File

@@ -0,0 +1,154 @@
// 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"
"net/url"
"strings"
"go.probo.inc/probo/pkg/coredata"
)
// LangfuseDriver lists the members of a single Langfuse organization via the
// organization-scoped public API. The organization API key (HTTP Basic,
// publicKey:secretKey) is bound to one organization on the configured host,
// so GET /api/public/organizations/memberships returns every member with no
// tenant selector. The Basic credential is applied by the connection
// transport; the base URL spans the regional cloud hosts and self-hosting.
type LangfuseDriver struct {
httpClient *http.Client
baseURL string
}
var _ Driver = (*LangfuseDriver)(nil)
type langfuseMembershipsResponse struct {
Memberships []langfuseMembership `json:"memberships"`
}
type langfuseMembership struct {
UserID string `json:"userId"`
Role string `json:"role"`
Email string `json:"email"`
Name string `json:"name"`
}
func NewLangfuseDriver(httpClient *http.Client, baseURL string) *LangfuseDriver {
return &LangfuseDriver{
httpClient: httpClient,
baseURL: baseURL,
}
}
func (d *LangfuseDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error) {
baseURL, err := url.Parse(d.baseURL)
if err != nil {
return nil, fmt.Errorf("cannot parse langfuse base URL: %w", err)
}
endpoint := baseURL.JoinPath("api", "public", "organizations", "memberships")
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil)
if err != nil {
return nil, fmt.Errorf("cannot create langfuse memberships request: %w", err)
}
req.Header.Set("Accept", "application/json")
httpResp, err := d.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("cannot execute langfuse memberships request: %w", err)
}
defer func() {
_ = httpResp.Body.Close()
}()
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
return nil, fmt.Errorf("cannot fetch langfuse memberships: unexpected status %d", httpResp.StatusCode)
}
var resp langfuseMembershipsResponse
if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil {
return nil, fmt.Errorf("cannot decode langfuse memberships response: %w", err)
}
records := make([]AccountRecord, 0, len(resp.Memberships))
for _, m := range resp.Memberships {
email := strings.TrimSpace(m.Email)
if email == "" {
continue
}
records = append(records, AccountRecord{
Email: email,
FullName: langfuseFullName(m, email),
Roles: langfuseRoles(m.Role),
IsAdmin: langfuseIsAdmin(m.Role),
MFAStatus: coredata.MFAStatusUnknown,
AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown,
AccountType: coredata.AccessReviewEntryAccountTypeUser,
ExternalID: strings.TrimSpace(m.UserID),
})
}
return records, nil
}
func langfuseFullName(m langfuseMembership, fallback string) string {
if name := strings.TrimSpace(m.Name); name != "" {
return name
}
return fallback
}
// langfuseRoles maps the Langfuse organization MembershipRole
// (OWNER/ADMIN/MEMBER/VIEWER, and the RBAC NONE) to a stable display label,
// preserving unknown future roles verbatim.
func langfuseRoles(role string) []string {
switch strings.ToUpper(strings.TrimSpace(role)) {
case "OWNER":
return []string{"Owner"}
case "ADMIN":
return []string{"Admin"}
case "MEMBER":
return []string{"Member"}
case "VIEWER":
return []string{"Viewer"}
case "NONE":
return []string{"None"}
default:
if r := strings.TrimSpace(role); r != "" {
return []string{r}
}
return []string{}
}
}
func langfuseIsAdmin(role string) bool {
switch strings.ToUpper(strings.TrimSpace(role)) {
case "OWNER", "ADMIN":
return true
default:
return false
}
}

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 TestLangfuseDriver(t *testing.T) {
t.Parallel()
rec := newRecorder(t, "testdata/langfuse", "LANGFUSE_API_KEY")
// Langfuse authenticates with HTTP Basic (publicKey:secretKey). The
// matcher ignores Authorization, so replay needs no auth.
client := newVCRClient(rec, basicAuthUserPass(os.Getenv("LANGFUSE_API_KEY")))
driver := NewLangfuseDriver(client, "https://cloud.langfuse.com")
records, err := driver.ListAccounts(context.Background())
require.NoError(t, err)
require.Len(t, records, 4)
owner := records[0]
assert.Equal(t, "lf-user-1", owner.ExternalID)
assert.Equal(t, "owner@example.com", owner.Email)
assert.Equal(t, "Olivia Owner", owner.FullName)
assert.Equal(t, []string{"Owner"}, owner.Roles)
assert.True(t, owner.IsAdmin)
assert.Equal(t, coredata.AccessReviewEntryAccountTypeUser, owner.AccountType)
admin := records[1]
assert.Equal(t, []string{"Admin"}, admin.Roles)
assert.True(t, admin.IsAdmin)
member := records[2]
assert.Equal(t, []string{"Member"}, member.Roles)
assert.False(t, member.IsAdmin)
viewer := records[3]
assert.Equal(t, []string{"Viewer"}, viewer.Roles)
assert.False(t, viewer.IsAdmin)
// name is empty in the payload, so the email is used as the display name.
assert.Equal(t, "viewer@example.com", viewer.FullName)
}
func TestLangfuseRoles(t *testing.T) {
t.Parallel()
assert.Equal(t, []string{"Owner"}, langfuseRoles("OWNER"))
assert.Equal(t, []string{"Admin"}, langfuseRoles("ADMIN"))
assert.Equal(t, []string{"Member"}, langfuseRoles("MEMBER"))
assert.Equal(t, []string{"Viewer"}, langfuseRoles("VIEWER"))
assert.Equal(t, []string{"None"}, langfuseRoles("NONE"))
assert.True(t, langfuseIsAdmin("OWNER"))
assert.True(t, langfuseIsAdmin("ADMIN"))
assert.False(t, langfuseIsAdmin("MEMBER"))
}

View File

@@ -0,0 +1,173 @@
// 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 (
mercuryUsersEndpoint = "https://api.mercury.com/api/v1/users"
// mercuryUsersPageSize is the page size requested from GET /api/v1/users.
// The API caps `limit` at 1000; 500 keeps responses small while still
// returning every member of typical organizations in one page.
mercuryUsersPageSize = 500
)
// MercuryDriver lists the users of a single Mercury organization. The
// access token (Bearer) is bound to one organization, so GET /api/v1/users
// returns every member of that organization with no tenant selector.
type MercuryDriver struct {
httpClient *http.Client
}
var _ Driver = (*MercuryDriver)(nil)
type mercuryUser struct {
UserID string `json:"userId"`
FirstName string `json:"firstName"`
LastName string `json:"lastName"`
Email string `json:"email"`
OrganizationRole string `json:"organizationRole"`
}
type mercuryUsersResponse struct {
Users []mercuryUser `json:"users"`
Page struct {
NextPage *string `json:"nextPage"`
} `json:"page"`
}
func NewMercuryDriver(httpClient *http.Client) *MercuryDriver {
return &MercuryDriver{httpClient: httpClient}
}
func (d *MercuryDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error) {
var (
records []AccountRecord
startAfter string
)
for range maxPaginationPages {
resp, err := d.fetchUsersPage(ctx, startAfter)
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: mercuryFullName(u, email),
Roles: mercuryRoles(u.OrganizationRole),
IsAdmin: u.OrganizationRole == "administrator",
MFAStatus: coredata.MFAStatusUnknown,
AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown,
AccountType: coredata.AccessReviewEntryAccountTypeUser,
ExternalID: strings.TrimSpace(u.UserID),
})
}
if resp.Page.NextPage == nil || *resp.Page.NextPage == "" {
return records, nil
}
startAfter = *resp.Page.NextPage
}
return nil, fmt.Errorf("cannot list all mercury users: %w", ErrPaginationLimitReached)
}
func (d *MercuryDriver) fetchUsersPage(ctx context.Context, startAfter string) (*mercuryUsersResponse, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, mercuryUsersEndpoint, nil)
if err != nil {
return nil, fmt.Errorf("cannot create mercury users request: %w", err)
}
q := req.URL.Query()
q.Set("limit", strconv.Itoa(mercuryUsersPageSize))
if startAfter != "" {
q.Set("start_after", startAfter)
}
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 mercury users request: %w", err)
}
defer func() {
_ = httpResp.Body.Close()
}()
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
return nil, fmt.Errorf("cannot fetch mercury users: unexpected status %d", httpResp.StatusCode)
}
var resp mercuryUsersResponse
if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil {
return nil, fmt.Errorf("cannot decode mercury users response: %w", err)
}
return &resp, nil
}
func mercuryFullName(u mercuryUser, fallback string) string {
fullName := strings.TrimSpace(strings.TrimSpace(u.FirstName) + " " + strings.TrimSpace(u.LastName))
if fullName != "" {
return fullName
}
return fallback
}
// mercuryRoles maps Mercury's organizationRole enum
// (administrator/bookkeeper/customUser/cardOnlyUser/employee) to a stable
// display label, preserving unknown future roles verbatim.
func mercuryRoles(role string) []string {
switch role {
case "administrator":
return []string{"Administrator"}
case "bookkeeper":
return []string{"Bookkeeper"}
case "customUser":
return []string{"Custom User"}
case "cardOnlyUser":
return []string{"Card Only User"}
case "employee":
return []string{"Employee"}
default:
if role != "" {
return []string{role}
}
return []string{}
}
}

View File

@@ -0,0 +1,78 @@
// 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 TestMercuryDriver(t *testing.T) {
t.Parallel()
rec := newRecorder(t, "testdata/mercury", "MERCURY_API_TOKEN")
client := newVCRClient(rec, bearerAuth(os.Getenv("MERCURY_API_TOKEN")))
driver := NewMercuryDriver(client)
records, err := driver.ListAccounts(context.Background())
require.NoError(t, err)
require.Len(t, records, 3)
admin := records[0]
assert.Equal(t, "8f1a6f1e-0000-4000-8000-000000000001", admin.ExternalID)
assert.Equal(t, "ada@example.com", admin.Email)
assert.Equal(t, "Ada Admin", admin.FullName)
assert.Equal(t, []string{"Administrator"}, admin.Roles)
assert.True(t, admin.IsAdmin)
assert.Equal(t, coredata.AccessReviewEntryAccountTypeUser, admin.AccountType)
assert.Equal(t, coredata.MFAStatusUnknown, admin.MFAStatus)
bookkeeper := records[1]
assert.Equal(t, []string{"Bookkeeper"}, bookkeeper.Roles)
assert.False(t, bookkeeper.IsAdmin)
employee := records[2]
assert.Equal(t, []string{"Employee"}, employee.Roles)
assert.False(t, employee.IsAdmin)
}
func TestMercuryRoles(t *testing.T) {
t.Parallel()
cases := []struct {
in string
want []string
}{
{"administrator", []string{"Administrator"}},
{"bookkeeper", []string{"Bookkeeper"}},
{"customUser", []string{"Custom User"}},
{"cardOnlyUser", []string{"Card Only User"}},
{"employee", []string{"Employee"}},
{"unknown_future_role", []string{"unknown_future_role"}},
}
for _, c := range cases {
t.Run(c.in, func(t *testing.T) {
t.Parallel()
assert.Equal(t, c.want, mercuryRoles(c.in))
})
}
}

View File

@@ -0,0 +1,37 @@
---
# Hand-authored fixture for GET /api/v1/users/search against an Apollo.io
# account (teammates, not the prospect DB). The user object shape (id, name,
# first_name, last_name, email, role) and the pagination wrapper mirror the
# documented response. 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.apollo.io
form:
page:
- "1"
per_page:
- "100"
headers:
Accept:
- application/json
url: https://api.apollo.io/api/v1/users/search?page=1&per_page=100
method: GET
response:
proto: HTTP/2.0
proto_major: 2
proto_minor: 0
content_length: -1
uncompressed: true
body: '{"users":[{"id":"5f0000000000000000000001","name":"Alice Admin","first_name":"Alice","last_name":"Admin","email":"alice@example.com","role":"Admin"},{"id":"5f0000000000000000000002","name":"Bob Rep","first_name":"Bob","last_name":"Rep","email":"bob@example.com","role":"Sales Rep"},{"id":"5f0000000000000000000003","name":"Carol Manager","first_name":"Carol","last_name":"Manager","email":"carol@example.com","role":"Billing and Seat Manager"},{"id":"5f0000000000000000000004","name":"","first_name":"","last_name":"","email":"dave@example.com","role":"Sales Rep"}],"pagination":{"page":1,"per_page":100,"total_entries":4,"total_pages":1}}'
headers:
Content-Type:
- application/json
status: 200 OK
code: 200
duration: 140ms

View File

@@ -0,0 +1,58 @@
---
# Hand-authored fixture for the ClickHouse Cloud member-listing flow: GET
# /v1/organizations (key maps to exactly one org) then GET
# /v1/organizations/{id}/members. Responses use the {status,requestId,result}
# envelope. assignedRoles is the live role source; the deprecated `role` field
# is the fallback. 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.clickhouse.cloud
headers:
Accept:
- application/json
url: https://api.clickhouse.cloud/v1/organizations
method: GET
response:
proto: HTTP/2.0
proto_major: 2
proto_minor: 0
content_length: -1
uncompressed: true
body: '{"status":200,"requestId":"11111111-1111-4111-8111-111111111111","result":[{"id":"aaaaaaaa-0000-4000-8000-000000000001","name":"Example Org","createdAt":"2025-12-01T08:00:00Z"}]}'
headers:
Content-Type:
- application/json
status: 200 OK
code: 200
duration: 110ms
- id: 1
request:
proto: HTTP/1.1
proto_major: 1
proto_minor: 1
content_length: 0
host: api.clickhouse.cloud
headers:
Accept:
- application/json
url: https://api.clickhouse.cloud/v1/organizations/aaaaaaaa-0000-4000-8000-000000000001/members
method: GET
response:
proto: HTTP/2.0
proto_major: 2
proto_minor: 0
content_length: -1
uncompressed: true
body: '{"status":200,"requestId":"22222222-2222-4222-8222-222222222222","result":[{"userId":"u-0000-0001","name":"Admin User","email":"admin@example.com","role":"admin","joinedAt":"2026-01-02T10:00:00Z","assignedRoles":[{"roleId":"33333333-3333-4333-8333-333333333333","roleName":"Admin","roleType":"system"}]},{"userId":"u-0000-0002","name":"Dev User","email":"dev@example.com","role":"developer","joinedAt":"2026-02-03T11:00:00Z","assignedRoles":[]},{"userId":"u-0000-0003","name":"","email":"custom@example.com","role":"developer","joinedAt":"2026-03-04T12:00:00Z","assignedRoles":[{"roleId":"44444444-4444-4444-8444-444444444444","roleName":"Billing Admin","roleType":"custom"}]}]}'
headers:
Content-Type:
- application/json
status: 200 OK
code: 200
duration: 110ms

View File

@@ -0,0 +1,82 @@
---
# Hand-authored fixture for the Deepgram account-listing flow: GET /v1/projects
# then GET /v1/projects/{project_id}/members for each project. owner@example.com
# belongs to both projects (with different scopes), exercising the dedup +
# scope-union path. 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.deepgram.com
headers:
Accept:
- application/json
url: https://api.deepgram.com/v1/projects
method: GET
response:
proto: HTTP/2.0
proto_major: 2
proto_minor: 0
content_length: -1
uncompressed: true
body: '{"projects":[{"project_id":"proj-1111-1111","name":"Production"},{"project_id":"proj-2222-2222","name":"Staging"}]}'
headers:
Content-Type:
- application/json
status: 200 OK
code: 200
duration: 90ms
- id: 1
request:
proto: HTTP/1.1
proto_major: 1
proto_minor: 1
content_length: 0
host: api.deepgram.com
headers:
Accept:
- application/json
url: https://api.deepgram.com/v1/projects/proj-1111-1111/members
method: GET
response:
proto: HTTP/2.0
proto_major: 2
proto_minor: 0
content_length: -1
uncompressed: true
body: '{"members":[{"member_id":"m-0000-0000-0001","email":"owner@example.com","first_name":"Olivia","last_name":"Owner","scopes":["member"]},{"member_id":"m-0000-0000-0002","email":"member@example.com","first_name":"Mia","last_name":"Member","scopes":["member"]}]}'
headers:
Content-Type:
- application/json
status: 200 OK
code: 200
duration: 90ms
- id: 2
request:
proto: HTTP/1.1
proto_major: 1
proto_minor: 1
content_length: 0
host: api.deepgram.com
headers:
Accept:
- application/json
url: https://api.deepgram.com/v1/projects/proj-2222-2222/members
method: GET
response:
proto: HTTP/2.0
proto_major: 2
proto_minor: 0
content_length: -1
uncompressed: true
body: '{"members":[{"member_id":"m-0000-0000-0001","email":"owner@example.com","first_name":"Olivia","last_name":"Owner","scopes":["owner"]},{"member_id":"m-0000-0000-0003","email":"dev@example.com","first_name":"Dan","last_name":"Dev","scopes":["member"]}]}'
headers:
Content-Type:
- application/json
status: 200 OK
code: 200
duration: 90ms

View File

@@ -0,0 +1,32 @@
---
# Hand-authored fixture for GET /api/public/organizations/memberships against a
# Langfuse organization (org-scoped API key). The membership object shape
# (userId, role, email, name) and the {memberships:[...]} wrapper mirror the
# documented response. Synthetic IDs/emails only.
version: 2
interactions:
- id: 0
request:
proto: HTTP/1.1
proto_major: 1
proto_minor: 1
content_length: 0
host: cloud.langfuse.com
headers:
Accept:
- application/json
url: https://cloud.langfuse.com/api/public/organizations/memberships
method: GET
response:
proto: HTTP/2.0
proto_major: 2
proto_minor: 0
content_length: -1
uncompressed: true
body: '{"memberships":[{"userId":"lf-user-1","role":"OWNER","email":"owner@example.com","name":"Olivia Owner"},{"userId":"lf-user-2","role":"ADMIN","email":"admin@example.com","name":"Adam Admin"},{"userId":"lf-user-3","role":"MEMBER","email":"member@example.com","name":"Mia Member"},{"userId":"lf-user-4","role":"VIEWER","email":"viewer@example.com","name":""}]}'
headers:
Content-Type:
- application/json
status: 200 OK
code: 200
duration: 100ms

View File

@@ -0,0 +1,35 @@
---
# Hand-authored fixture for GET /api/v1/users against a Mercury organization.
# The user object shape (userId, firstName, lastName, email, organizationRole)
# mirrors the documented UserDetails schema; the page cursor is absent so the
# driver stops after one page. 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.mercury.com
form:
limit:
- "500"
headers:
Accept:
- application/json
url: https://api.mercury.com/api/v1/users?limit=500
method: GET
response:
proto: HTTP/2.0
proto_major: 2
proto_minor: 0
content_length: -1
uncompressed: true
body: '{"users":[{"userId":"8f1a6f1e-0000-4000-8000-000000000001","firstName":"Ada","lastName":"Admin","email":"ada@example.com","organizationRole":"administrator"},{"userId":"8f1a6f1e-0000-4000-8000-000000000002","firstName":"Ben","lastName":"Books","email":"ben@example.com","organizationRole":"bookkeeper"},{"userId":"8f1a6f1e-0000-4000-8000-000000000003","firstName":"Eve","lastName":"Employee","email":"eve@example.com","organizationRole":"employee"}],"page":{"nextPage":null,"previousPage":null}}'
headers:
Content-Type:
- application/json
status: 200 OK
code: 200
duration: 120ms

View File

@@ -114,6 +114,20 @@ func basicAuth(username string) string {
return "Basic " + base64.StdEncoding.EncodeToString([]byte(username+":"))
}
// basicAuthUserPass returns the HTTP Basic auth header value for a credential
// that already holds the "username:password" pair ("Basic
// base64(<credential>)"), or "" if the credential is empty. ClickHouse
// Cloud (keyId:keySecret) and Langfuse (publicKey:secretKey) present such
// a credential. The matcher ignores Authorization, so this only matters
// when re-recording.
func basicAuthUserPass(credential string) string {
if credential == "" {
return ""
}
return "Basic " + base64.StdEncoding.EncodeToString([]byte(credential))
}
// newVCRClient creates an *http.Client backed by the recorder's transport,
// with an optional Authorization header injected into requests (for recording
// mode). The authValue should be the complete header value, e.g.