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,13 @@
import type { ComponentProps } from "react";
export function Apollo(props: ComponentProps<"svg">) {
return (
<svg viewBox="0 0 128 128" xmlns="http://www.w3.org/2000/svg" {...props}>
<rect width="128" height="128" fill="#FECF40" rx="8" />
<path
fill="#000"
d="M91.044 36.135H78.489l6.51 11.134 6.045-11.134ZM108.494 98.987 63.922 25 19.5 98.832h23.792c3.174 0 6.297-.8 9.058-2.316 2.98-1.638 5.23-4.012 6.989-6.89 2.056-3.367 4.053-6.773 6.077-10.16l5.178-8.67-6.678-11.167-2.961 4.755c-3.375 5.631-6.569 11.392-10.066 16.947-1.759 2.786-4.054 5.418-7.442 6.096a9.686 9.686 0 0 1-1.558.174c-.698.026-1.397.013-2.088.013l24.121-40.99 30.975 52.363h13.597Z"
/>
</svg>
);
}

View File

@@ -0,0 +1,9 @@
import type { ComponentProps } from "react";
export function ClickHouse(props: ComponentProps<"svg">) {
return (
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" {...props}>
<path fill="#FAFF69" d="M21.333 10H24v4h-2.667ZM16 1.335h2.667v21.33H16Zm-5.333 0h2.666v21.33h-2.666ZM0 22.665V1.335h2.667v21.33zm5.333-21.33H8v21.33H5.333Z" />
</svg>
);
}

View File

@@ -0,0 +1,9 @@
import type { ComponentProps } from "react";
export function Deepgram(props: ComponentProps<"svg">) {
return (
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" {...props}>
<path fill="#13EF93" d="M11.203 24H1.517a.364.364 0 0 1-.258-.62l6.239-6.275a.366.366 0 0 1 .259-.108h3.52c2.723 0 5.025-2.127 5.107-4.845a5.004 5.004 0 0 0-4.999-5.148H7.613v4.646c0 .2-.164.364-.365.364H.968a.365.365 0 0 1-.363-.364V.364C.605.164.768 0 .969 0h10.416c6.684 0 12.111 5.485 12.01 12.187C23.293 18.77 17.794 24 11.202 24z" />
</svg>
);
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -15,15 +15,18 @@
import type { ComponentProps, FC } from "react";
import { Anthropic } from "./Anthropic";
import { Apollo } from "./Apollo";
import { Asana } from "./Asana";
import { BetterStack } from "./BetterStack";
import { Bitbucket } from "./Bitbucket";
import { Brex } from "./Brex";
import { Clerk } from "./Clerk";
import { ClickHouse } from "./ClickHouse";
import { ClickUp } from "./ClickUp";
import { Cloudflare } from "./Cloudflare";
import { Cursor } from "./Cursor";
import { Datadog } from "./Datadog";
import { Deepgram } from "./Deepgram";
import { DocuSign } from "./DocuSign";
import { Figma } from "./Figma";
import { GitHub } from "./GitHub";
@@ -33,7 +36,9 @@ import { Grafana } from "./Grafana";
import { Heroku } from "./Heroku";
import { HubSpot } from "./HubSpot";
import { Intercom } from "./Intercom";
import { Langfuse } from "./Langfuse";
import { Linear } from "./Linear";
import { Mercury } from "./Mercury";
import { Metabase } from "./Metabase";
import { Microsoft } from "./Microsoft";
import { Monday } from "./Monday";
@@ -60,15 +65,18 @@ import { Zendesk } from "./Zendesk";
const thirdParties: Record<string, FC<ComponentProps<"svg">>> = {
ANTHROPIC: Anthropic,
APOLLO: Apollo,
ASANA: Asana,
BETTER_STACK: BetterStack,
BITBUCKET: Bitbucket,
BREX: Brex,
CLERK: Clerk,
CLICKHOUSE: ClickHouse,
CLICKUP: ClickUp,
CLOUDFLARE: Cloudflare,
CURSOR: Cursor,
DATADOG: Datadog,
DEEPGRAM: Deepgram,
DOCUSIGN: DocuSign,
FIGMA: Figma,
GITHUB: GitHub,
@@ -79,7 +87,9 @@ const thirdParties: Record<string, FC<ComponentProps<"svg">>> = {
HEROKU: Heroku,
HUBSPOT: HubSpot,
INTERCOM: Intercom,
LANGFUSE: Langfuse,
LINEAR: Linear,
MERCURY: Mercury,
METABASE: Metabase,
MICROSOFT: Microsoft,
MICROSOFT_365: Microsoft,

View File

@@ -1,13 +1,16 @@
export { Anthropic } from "./Anthropic";
export { Apollo } from "./Apollo";
export { Asana } from "./Asana";
export { BetterStack } from "./BetterStack";
export { Bitbucket } from "./Bitbucket";
export { Brex } from "./Brex";
export { Clerk } from "./Clerk";
export { ClickHouse } from "./ClickHouse";
export { ClickUp } from "./ClickUp";
export { Cloudflare } from "./Cloudflare";
export { Cursor } from "./Cursor";
export { Datadog } from "./Datadog";
export { Deepgram } from "./Deepgram";
export { DocuSign } from "./DocuSign";
export { Figma } from "./Figma";
export { GitHub } from "./GitHub";
@@ -17,7 +20,9 @@ export { Grafana } from "./Grafana";
export { Heroku } from "./Heroku";
export { HubSpot } from "./HubSpot";
export { Intercom } from "./Intercom";
export { Langfuse } from "./Langfuse";
export { Linear } from "./Linear";
export { Mercury } from "./Mercury";
export { Metabase } from "./Metabase";
export { Microsoft } from "./Microsoft";
export { Monday } from "./Monday";

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.

View File

@@ -0,0 +1,51 @@
// 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 provider
import (
"context"
"net/http"
"go.gearno.de/kit/log"
"go.probo.inc/probo/pkg/accessreview/drivers"
"go.probo.inc/probo/pkg/coredata"
)
func apolloRegistration() *Registration {
return &Registration{
Provider: coredata.ConnectorProviderApollo,
DisplayName: "Apollo.io",
SupportsAPIKey: true,
// Apollo's REST API authenticates with a master API key in the
// x-api-key header; it rejects Authorization: Bearer (and, since
// Sept 2024, query/body key params). APIKeyHeader makes the
// APIKeyConnection send x-api-key instead of Bearer. There is no
// OAuth2 flow needed: the customer supplies a master key, which is
// bound to one Apollo account, so there is nothing to pick
// (Pattern 3): no settings struct, no picker.
APIKeyHeader: "x-api-key",
// ProbeURL lets the connection-status check confirm the key with a
// lightweight GET; the transport attaches x-api-key, and a missing,
// dead, or non-master key returns 401/403.
ProbeURL: "https://api.apollo.io/api/v1/users/search?page=1&per_page=1",
//
// No NewNameResolver: Apollo exposes no stable account-name
// endpoint reachable with the master key, so the source keeps its
// generic name.
NewDriver: func(_ context.Context, c *http.Client, _ *coredata.Connector, _ *log.Logger) (drivers.Driver, error) {
return drivers.NewApolloDriver(c), nil
},
}
}

View File

@@ -23,15 +23,18 @@ func NewBuiltinRegistry() *Registry {
r := NewRegistry()
for _, reg := range []*Registration{
anthropicRegistration(),
apolloRegistration(),
asanaRegistration(),
betterStackRegistration(),
bitbucketRegistration(),
brexRegistration(),
clerkRegistration(),
clickhouseRegistration(),
clickupRegistration(),
cloudflareRegistration(),
cursorRegistration(),
datadogRegistration(),
deepgramRegistration(),
docusignRegistration(),
grafanaRegistration(),
githubRegistration(),
@@ -40,7 +43,9 @@ func NewBuiltinRegistry() *Registry {
herokuRegistration(),
hubspotRegistration(),
intercomRegistration(),
langfuseRegistration(),
linearRegistration(),
mercuryRegistration(),
metabaseRegistration(),
microsoft365Registration(),
mondayRegistration(),

View File

@@ -0,0 +1,52 @@
// 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 provider
import (
"context"
"net/http"
"go.gearno.de/kit/log"
"go.probo.inc/probo/pkg/accessreview/drivers"
"go.probo.inc/probo/pkg/coredata"
)
func clickhouseRegistration() *Registration {
return &Registration{
Provider: coredata.ConnectorProviderClickHouse,
DisplayName: "ClickHouse Cloud",
SupportsAPIKey: true,
// ClickHouse Cloud's control-plane API authenticates with HTTP Basic
// auth where the credential is keyId:keySecret. APIKeyBasicAuthUserPass
// makes the APIKeyConnection base64 the verbatim "keyId:keySecret"
// the operator pastes (the empty-password APIKeyBasicAuth cannot
// carry the secret). There is no OAuth2 flow; a key/secret pair is
// scoped to exactly one organization, which the driver discovers via
// GET /v1/organizations, so there is nothing to pick or configure
// (Pattern 3): no settings struct, no picker.
APIKeyBasicAuthUserPass: true,
// ProbeURL lets the connection-status check confirm the key/secret
// with a lightweight GET; the transport attaches the Basic
// credential and a dead key/secret returns 401/403.
ProbeURL: "https://api.clickhouse.cloud/v1/organizations",
//
// No NewNameResolver: the organization name is available but would
// duplicate the driver's discovery call; the source keeps its
// generic name.
NewDriver: func(_ context.Context, c *http.Client, _ *coredata.Connector, _ *log.Logger) (drivers.Driver, error) {
return drivers.NewClickHouseDriver(c), nil
},
}
}

View File

@@ -0,0 +1,49 @@
// 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 provider
import (
"context"
"net/http"
"go.gearno.de/kit/log"
"go.probo.inc/probo/pkg/accessreview/drivers"
"go.probo.inc/probo/pkg/coredata"
)
func deepgramRegistration() *Registration {
return &Registration{
Provider: coredata.ConnectorProviderDeepgram,
DisplayName: "Deepgram",
SupportsAPIKey: true,
// Deepgram authenticates with an API key under the `Token` scheme
// (`Authorization: Token <key>`), not Bearer. APIKeyAuthScheme makes
// the APIKeyConnection use that scheme. There is no third-party
// OAuth2 flow; the customer supplies an owner/admin key bound to one
// account, so there is nothing to pick (Pattern 3): no settings
// struct, no picker.
APIKeyAuthScheme: "Token",
// ProbeURL lets the connection-status check confirm the key with a
// lightweight GET; the transport attaches the `Token` credential and
// a dead key returns 401/403.
ProbeURL: "https://api.deepgram.com/v1/projects",
//
// No NewNameResolver: an account may span several projects, so there
// is no single instance name; the source keeps its generic name.
NewDriver: func(_ context.Context, c *http.Client, _ *coredata.Connector, _ *log.Logger) (drivers.Driver, error) {
return drivers.NewDeepgramDriver(c), nil
},
}
}

View File

@@ -0,0 +1,89 @@
// 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 provider
import (
"context"
"fmt"
"net/http"
"net/url"
"strings"
"go.gearno.de/kit/log"
"go.probo.inc/probo/pkg/accessreview/drivers"
"go.probo.inc/probo/pkg/coredata"
)
func langfuseRegistration() *Registration {
return &Registration{
Provider: coredata.ConnectorProviderLangfuse,
DisplayName: "Langfuse",
SupportsAPIKey: true,
// Langfuse's organization-scoped public API authenticates with HTTP
// Basic auth where the credential is publicKey:secretKey.
// APIKeyBasicAuthUserPass base64s the verbatim "publicKey:secretKey" the
// operator pastes (the empty-password APIKeyBasicAuth cannot carry
// the secret). The org API key is bound to one organization, so
// there is nothing to pick; only the regional/self-hosted base URL
// is per-tenant and is surfaced as an extra setting.
APIKeyBasicAuthUserPass: true,
ExtraSettings: []ExtraSetting{
{Key: "baseUrl", Label: "Base URL", Required: true},
},
// BuildProbeURL derives the probe endpoint from the per-connection
// base URL (the host is regional/self-hosted, so a static ProbeURL
// cannot express it); the transport attaches the Basic credential
// and a dead key returns 401/403.
BuildProbeURL: buildLangfuseProbeURL,
//
// No NewNameResolver: the memberships endpoint carries no
// organization name, so the source keeps its generic name.
NewDriver: func(_ context.Context, c *http.Client, conn *coredata.Connector, _ *log.Logger) (drivers.Driver, error) {
settings, err := coredata.ConnectorSettings[coredata.LangfuseConnectorSettings](conn)
if err != nil {
return nil, fmt.Errorf("cannot read langfuse connector settings: %w", err)
}
baseURL, err := normalizeLangfuseBaseURL(settings.BaseURL)
if err != nil {
return nil, fmt.Errorf("cannot create langfuse driver: %w", err)
}
return drivers.NewLangfuseDriver(c, baseURL), nil
},
}
}
func normalizeLangfuseBaseURL(raw string) (string, error) {
baseURL := strings.TrimSpace(raw)
if baseURL == "" {
return "", fmt.Errorf("base_url is required")
}
u, err := url.Parse(baseURL)
if err != nil {
return "", fmt.Errorf("base_url must be a valid URL: %w", err)
}
if (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
return "", fmt.Errorf("base_url must be an http(s) URL")
}
u.Path = strings.TrimRight(u.Path, "/")
u.RawQuery = ""
u.Fragment = ""
return u.String(), nil
}

View File

@@ -0,0 +1,108 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package provider_test
import (
"context"
"encoding/json"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.gearno.de/kit/httpclient"
"go.probo.inc/probo/pkg/accessreview/drivers"
"go.probo.inc/probo/pkg/connector/provider"
"go.probo.inc/probo/pkg/coredata"
)
func TestLangfuseRegistrationMetadata(t *testing.T) {
t.Parallel()
r := provider.NewBuiltinRegistry()
reg, ok := r.Get(coredata.ConnectorProviderLangfuse)
require.True(t, ok, "langfuse provider must be registered")
assert.Equal(t, "Langfuse", reg.DisplayName)
assert.True(t, reg.SupportsAPIKey)
// Langfuse presents publicKey:secretKey as a full HTTP Basic credential.
assert.True(t, reg.APIKeyBasicAuthUserPass)
assert.Empty(t, reg.APIKeyHeader)
assert.Empty(t, reg.APIKeyAuthScheme)
require.Len(t, reg.ExtraSettings, 1)
assert.Equal(t, "baseUrl", reg.ExtraSettings[0].Key)
assert.Equal(t, "Base URL", reg.ExtraSettings[0].Label)
assert.True(t, reg.ExtraSettings[0].Required)
// Single-tenant API-key provider: no picker, no name resolver.
assert.Nil(t, reg.NewNameResolver, "langfuse must not wire a name resolver")
assert.Nil(t, reg.SetOrganizationSettings, "langfuse must not wire a picker store")
}
func TestLangfuseNewDriver(t *testing.T) {
t.Parallel()
r := provider.NewBuiltinRegistry()
reg, ok := r.Get(coredata.ConnectorProviderLangfuse)
require.True(t, ok, "langfuse provider must be registered")
require.NotNil(t, reg.NewDriver, "langfuse NewDriver closure must be wired")
t.Run("creates driver with valid base_url", func(t *testing.T) {
t.Parallel()
raw, err := json.Marshal(&coredata.LangfuseConnectorSettings{
BaseURL: "https://cloud.langfuse.com",
})
require.NoError(t, err)
conn := &coredata.Connector{
Provider: coredata.ConnectorProviderLangfuse,
RawSettings: raw,
}
drv, err := reg.NewDriver(context.Background(), httpclient.DefaultClient(httpclient.WithSSRFProtection()), conn, nil)
require.NoError(t, err)
assert.IsType(t, &drivers.LangfuseDriver{}, drv)
})
t.Run("errors when base_url is missing", func(t *testing.T) {
t.Parallel()
conn := &coredata.Connector{
Provider: coredata.ConnectorProviderLangfuse,
RawSettings: []byte(`{}`),
}
_, err := reg.NewDriver(context.Background(), httpclient.DefaultClient(httpclient.WithSSRFProtection()), conn, nil)
require.Error(t, err)
assert.Contains(t, err.Error(), "base_url is required")
})
t.Run("errors when base_url is invalid", func(t *testing.T) {
t.Parallel()
raw, err := json.Marshal(&coredata.LangfuseConnectorSettings{
BaseURL: "ftp://cloud.langfuse.com",
})
require.NoError(t, err)
conn := &coredata.Connector{
Provider: coredata.ConnectorProviderLangfuse,
RawSettings: raw,
}
_, err = reg.NewDriver(context.Background(), httpclient.DefaultClient(httpclient.WithSSRFProtection()), conn, nil)
require.Error(t, err)
assert.Contains(t, err.Error(), "base_url must be an http(s) URL")
})
}

View File

@@ -0,0 +1,51 @@
// 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 provider
import (
"context"
"net/http"
"go.gearno.de/kit/log"
"go.probo.inc/probo/pkg/accessreview/drivers"
"go.probo.inc/probo/pkg/coredata"
)
func mercuryRegistration() *Registration {
return &Registration{
Provider: coredata.ConnectorProviderMercury,
DisplayName: "Mercury",
SupportsAPIKey: true,
// Mercury authenticates with a self-serve API token presented as
// Authorization: Bearer, the default APIKeyConnection scheme. There
// is no third-party OAuth2 flow for the Users API. The token is
// bound to one Mercury organization, so there is nothing to pick
// (Pattern 3): no settings struct, no picker, no
// SetOrganizationSettings.
//
// ProbeURL lets the connection-status check confirm the token is
// live with a lightweight GET; the transport attaches the Bearer
// token and a dead token returns 401/403.
ProbeURL: "https://api.mercury.com/api/v1/users?limit=1",
//
// No NewNameResolver: GET /api/v1/users carries no organization
// name and a read-only token may lack other scopes, so the source
// keeps its generic name (the source-name worker degrades
// gracefully).
NewDriver: func(_ context.Context, c *http.Client, _ *coredata.Connector, _ *log.Logger) (drivers.Driver, error) {
return drivers.NewMercuryDriver(c), nil
},
}
}

View File

@@ -314,6 +314,25 @@ func buildMetabaseProbeURL(conn *coredata.Connector) (string, error) {
return endpoint.String(), nil
}
func buildLangfuseProbeURL(conn *coredata.Connector) (string, error) {
s, err := coredata.ConnectorSettings[coredata.LangfuseConnectorSettings](conn)
if err != nil {
return "", fmt.Errorf("cannot read langfuse connector settings: %w", err)
}
baseURL, err := normalizeLangfuseBaseURL(s.BaseURL)
if err != nil {
return "", err
}
u, err := url.Parse(baseURL)
if err != nil {
return "", fmt.Errorf("cannot parse langfuse base URL: %w", err)
}
return u.JoinPath("api", "public", "organizations", "memberships").String(), nil
}
func buildSigNozProbeURL(conn *coredata.Connector) (string, error) {
s, err := coredata.ConnectorSettings[coredata.SigNozConnectorSettings](conn)
if err != nil {

View File

@@ -78,6 +78,19 @@ func TestBuildOktaProbeURL(t *testing.T) {
assert.Equal(t, "https://acme.okta.com/api/v1/users?limit=1", probeURL)
}
func TestBuildLangfuseProbeURL(t *testing.T) {
t.Parallel()
conn := &coredata.Connector{Provider: coredata.ConnectorProviderLangfuse}
require.NoError(t, conn.SetSettings(&coredata.LangfuseConnectorSettings{
BaseURL: "https://us.cloud.langfuse.com",
}))
probeURL, err := buildLangfuseProbeURL(conn)
require.NoError(t, err)
assert.Equal(t, "https://us.cloud.langfuse.com/api/public/organizations/memberships", probeURL)
}
func TestBuildPostHogProbeURL(t *testing.T) {
t.Parallel()

View File

@@ -65,6 +65,11 @@ const (
ConnectorProviderQovery ConnectorProvider = "QOVERY"
ConnectorProviderRender ConnectorProvider = "RENDER"
ConnectorProviderNeon ConnectorProvider = "NEON"
ConnectorProviderMercury ConnectorProvider = "MERCURY"
ConnectorProviderApollo ConnectorProvider = "APOLLO"
ConnectorProviderDeepgram ConnectorProvider = "DEEPGRAM"
ConnectorProviderClickHouse ConnectorProvider = "CLICKHOUSE"
ConnectorProviderLangfuse ConnectorProvider = "LANGFUSE"
)
var (
@@ -117,6 +122,11 @@ func ConnectorProviders() []ConnectorProvider {
ConnectorProviderQovery,
ConnectorProviderRender,
ConnectorProviderNeon,
ConnectorProviderMercury,
ConnectorProviderApollo,
ConnectorProviderDeepgram,
ConnectorProviderClickHouse,
ConnectorProviderLangfuse,
}
}
@@ -164,7 +174,12 @@ func (v ConnectorProvider) IsValid() bool {
ConnectorProviderZendesk,
ConnectorProviderQovery,
ConnectorProviderRender,
ConnectorProviderNeon:
ConnectorProviderNeon,
ConnectorProviderMercury,
ConnectorProviderApollo,
ConnectorProviderDeepgram,
ConnectorProviderClickHouse,
ConnectorProviderLangfuse:
return true
}

View File

@@ -156,6 +156,15 @@ type (
NeonConnectorSettings struct {
OrganizationID string `json:"organization_id"`
}
// LangfuseConnectorSettings carries the Langfuse API base URL, which
// spans the regional cloud hosts (cloud.langfuse.com /
// us.cloud.langfuse.com / …) and self-hosted instances. The
// organization-scoped API key is bound to a single organization on
// that host, so the base URL is the only per-tenant setting.
LangfuseConnectorSettings struct {
BaseURL string `json:"base_url"`
}
)
// GrantType returns the OAuth2 grant type recorded on the connector's

View File

@@ -0,0 +1,15 @@
-- 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.
ALTER TYPE connector_provider ADD VALUE IF NOT EXISTS 'LANGFUSE';

View File

@@ -0,0 +1,15 @@
-- 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.
ALTER TYPE connector_provider ADD VALUE IF NOT EXISTS 'APOLLO';

View File

@@ -0,0 +1,15 @@
-- 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.
ALTER TYPE connector_provider ADD VALUE IF NOT EXISTS 'CLICKHOUSE';

View File

@@ -0,0 +1,15 @@
-- 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.
ALTER TYPE connector_provider ADD VALUE IF NOT EXISTS 'MERCURY';

View File

@@ -0,0 +1,15 @@
-- 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.
ALTER TYPE connector_provider ADD VALUE IF NOT EXISTS 'DEEPGRAM';

View File

@@ -178,6 +178,17 @@ func apiKeyConnectorSettings(input types.CreateAPIKeyConnectorInput) (json.RawMe
}
return json.Marshal(&coredata.NeonConnectorSettings{OrganizationID: *input.NeonOrganizationID})
case coredata.ConnectorProviderLangfuse:
if input.LangfuseBaseURL == nil || *input.LangfuseBaseURL == "" {
return nil, fmt.Errorf("cannot create langfuse connector: langfuseBaseUrl is required")
}
u, err := url.Parse(*input.LangfuseBaseURL)
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
return nil, fmt.Errorf("cannot create langfuse connector: langfuseBaseUrl must be an http(s) URL")
}
return json.Marshal(&coredata.LangfuseConnectorSettings{BaseURL: *input.LangfuseBaseURL})
}
return nil, nil

View File

@@ -71,6 +71,17 @@ enum ConnectorProvider
QOVERY @goEnum(value: "go.probo.inc/probo/pkg/coredata.ConnectorProviderQovery")
RENDER @goEnum(value: "go.probo.inc/probo/pkg/coredata.ConnectorProviderRender")
NEON @goEnum(value: "go.probo.inc/probo/pkg/coredata.ConnectorProviderNeon")
MERCURY
@goEnum(value: "go.probo.inc/probo/pkg/coredata.ConnectorProviderMercury")
APOLLO @goEnum(value: "go.probo.inc/probo/pkg/coredata.ConnectorProviderApollo")
DEEPGRAM
@goEnum(value: "go.probo.inc/probo/pkg/coredata.ConnectorProviderDeepgram")
CLICKHOUSE
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.ConnectorProviderClickHouse"
)
LANGFUSE
@goEnum(value: "go.probo.inc/probo/pkg/coredata.ConnectorProviderLangfuse")
}
type ConnectorProviderInfo {
@@ -152,6 +163,7 @@ input CreateAPIKeyConnectorInput {
qoveryOrganizationId: String
renderWorkspaceId: String
neonOrganizationId: String
langfuseBaseUrl: String
}
type CreateAPIKeyConnectorPayload {