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:
196
pkg/accessreview/drivers/apollo.go
Normal file
196
pkg/accessreview/drivers/apollo.go
Normal 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")
|
||||
}
|
||||
70
pkg/accessreview/drivers/apollo_test.go
Normal file
70
pkg/accessreview/drivers/apollo_test.go
Normal 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"))
|
||||
}
|
||||
224
pkg/accessreview/drivers/clickhouse.go
Normal file
224
pkg/accessreview/drivers/clickhouse.go
Normal 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")
|
||||
}
|
||||
59
pkg/accessreview/drivers/clickhouse_test.go
Normal file
59
pkg/accessreview/drivers/clickhouse_test.go
Normal 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)
|
||||
}
|
||||
244
pkg/accessreview/drivers/deepgram.go
Normal file
244
pkg/accessreview/drivers/deepgram.go
Normal 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
|
||||
}
|
||||
85
pkg/accessreview/drivers/deepgram_test.go
Normal file
85
pkg/accessreview/drivers/deepgram_test.go
Normal 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))
|
||||
}
|
||||
154
pkg/accessreview/drivers/langfuse.go
Normal file
154
pkg/accessreview/drivers/langfuse.go
Normal 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
|
||||
}
|
||||
}
|
||||
74
pkg/accessreview/drivers/langfuse_test.go
Normal file
74
pkg/accessreview/drivers/langfuse_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 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"))
|
||||
}
|
||||
173
pkg/accessreview/drivers/mercury.go
Normal file
173
pkg/accessreview/drivers/mercury.go
Normal 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{}
|
||||
}
|
||||
}
|
||||
78
pkg/accessreview/drivers/mercury_test.go
Normal file
78
pkg/accessreview/drivers/mercury_test.go
Normal 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))
|
||||
})
|
||||
}
|
||||
}
|
||||
37
pkg/accessreview/drivers/testdata/apollo.yaml
vendored
Normal file
37
pkg/accessreview/drivers/testdata/apollo.yaml
vendored
Normal 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
|
||||
58
pkg/accessreview/drivers/testdata/clickhouse.yaml
vendored
Normal file
58
pkg/accessreview/drivers/testdata/clickhouse.yaml
vendored
Normal 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
|
||||
82
pkg/accessreview/drivers/testdata/deepgram.yaml
vendored
Normal file
82
pkg/accessreview/drivers/testdata/deepgram.yaml
vendored
Normal 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
|
||||
32
pkg/accessreview/drivers/testdata/langfuse.yaml
vendored
Normal file
32
pkg/accessreview/drivers/testdata/langfuse.yaml
vendored
Normal 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
|
||||
35
pkg/accessreview/drivers/testdata/mercury.yaml
vendored
Normal file
35
pkg/accessreview/drivers/testdata/mercury.yaml
vendored
Normal 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
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user