diff --git a/packages/ui/src/Atoms/ThirdParties/Apollo.tsx b/packages/ui/src/Atoms/ThirdParties/Apollo.tsx new file mode 100644 index 000000000..47bb447b9 --- /dev/null +++ b/packages/ui/src/Atoms/ThirdParties/Apollo.tsx @@ -0,0 +1,13 @@ +import type { ComponentProps } from "react"; + +export function Apollo(props: ComponentProps<"svg">) { + return ( + + + + + ); +} diff --git a/packages/ui/src/Atoms/ThirdParties/ClickHouse.tsx b/packages/ui/src/Atoms/ThirdParties/ClickHouse.tsx new file mode 100644 index 000000000..c8852fd59 --- /dev/null +++ b/packages/ui/src/Atoms/ThirdParties/ClickHouse.tsx @@ -0,0 +1,9 @@ +import type { ComponentProps } from "react"; + +export function ClickHouse(props: ComponentProps<"svg">) { + return ( + + + + ); +} diff --git a/packages/ui/src/Atoms/ThirdParties/Deepgram.tsx b/packages/ui/src/Atoms/ThirdParties/Deepgram.tsx new file mode 100644 index 000000000..c43dc844f --- /dev/null +++ b/packages/ui/src/Atoms/ThirdParties/Deepgram.tsx @@ -0,0 +1,9 @@ +import type { ComponentProps } from "react"; + +export function Deepgram(props: ComponentProps<"svg">) { + return ( + + + + ); +} diff --git a/packages/ui/src/Atoms/ThirdParties/Langfuse.tsx b/packages/ui/src/Atoms/ThirdParties/Langfuse.tsx new file mode 100644 index 000000000..8ce644370 --- /dev/null +++ b/packages/ui/src/Atoms/ThirdParties/Langfuse.tsx @@ -0,0 +1,15 @@ +import type { ComponentProps } from "react"; + +export function Langfuse(props: ComponentProps<"svg">) { + return ( + + + + + + + + + + ); +} diff --git a/packages/ui/src/Atoms/ThirdParties/Mercury.tsx b/packages/ui/src/Atoms/ThirdParties/Mercury.tsx new file mode 100644 index 000000000..ff686287f --- /dev/null +++ b/packages/ui/src/Atoms/ThirdParties/Mercury.tsx @@ -0,0 +1,9 @@ +import type { ComponentProps } from "react"; + +export function Mercury(props: ComponentProps<"svg">) { + return ( + + + + ); +} diff --git a/packages/ui/src/Atoms/ThirdParties/ThirdPartyLogo.tsx b/packages/ui/src/Atoms/ThirdParties/ThirdPartyLogo.tsx index 910148feb..a2318c2c0 100644 --- a/packages/ui/src/Atoms/ThirdParties/ThirdPartyLogo.tsx +++ b/packages/ui/src/Atoms/ThirdParties/ThirdPartyLogo.tsx @@ -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>> = { 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>> = { HEROKU: Heroku, HUBSPOT: HubSpot, INTERCOM: Intercom, + LANGFUSE: Langfuse, LINEAR: Linear, + MERCURY: Mercury, METABASE: Metabase, MICROSOFT: Microsoft, MICROSOFT_365: Microsoft, diff --git a/packages/ui/src/Atoms/ThirdParties/index.ts b/packages/ui/src/Atoms/ThirdParties/index.ts index 8a1128f57..4b8c370df 100644 --- a/packages/ui/src/Atoms/ThirdParties/index.ts +++ b/packages/ui/src/Atoms/ThirdParties/index.ts @@ -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"; diff --git a/pkg/accessreview/drivers/apollo.go b/pkg/accessreview/drivers/apollo.go new file mode 100644 index 000000000..c58efdc24 --- /dev/null +++ b/pkg/accessreview/drivers/apollo.go @@ -0,0 +1,196 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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") +} diff --git a/pkg/accessreview/drivers/apollo_test.go b/pkg/accessreview/drivers/apollo_test.go new file mode 100644 index 000000000..f3a0da97b --- /dev/null +++ b/pkg/accessreview/drivers/apollo_test.go @@ -0,0 +1,70 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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")) +} diff --git a/pkg/accessreview/drivers/clickhouse.go b/pkg/accessreview/drivers/clickhouse.go new file mode 100644 index 000000000..b325f1e66 --- /dev/null +++ b/pkg/accessreview/drivers/clickhouse.go @@ -0,0 +1,224 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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") +} diff --git a/pkg/accessreview/drivers/clickhouse_test.go b/pkg/accessreview/drivers/clickhouse_test.go new file mode 100644 index 000000000..a1db0a6b2 --- /dev/null +++ b/pkg/accessreview/drivers/clickhouse_test.go @@ -0,0 +1,59 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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) +} diff --git a/pkg/accessreview/drivers/deepgram.go b/pkg/accessreview/drivers/deepgram.go new file mode 100644 index 000000000..74df2db6c --- /dev/null +++ b/pkg/accessreview/drivers/deepgram.go @@ -0,0 +1,244 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 ` +// 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 +} diff --git a/pkg/accessreview/drivers/deepgram_test.go b/pkg/accessreview/drivers/deepgram_test.go new file mode 100644 index 000000000..ad4cbe2a5 --- /dev/null +++ b/pkg/accessreview/drivers/deepgram_test.go @@ -0,0 +1,85 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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)) +} diff --git a/pkg/accessreview/drivers/langfuse.go b/pkg/accessreview/drivers/langfuse.go new file mode 100644 index 000000000..71c976eb5 --- /dev/null +++ b/pkg/accessreview/drivers/langfuse.go @@ -0,0 +1,154 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 + } +} diff --git a/pkg/accessreview/drivers/langfuse_test.go b/pkg/accessreview/drivers/langfuse_test.go new file mode 100644 index 000000000..e31dbc5fd --- /dev/null +++ b/pkg/accessreview/drivers/langfuse_test.go @@ -0,0 +1,74 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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")) +} diff --git a/pkg/accessreview/drivers/mercury.go b/pkg/accessreview/drivers/mercury.go new file mode 100644 index 000000000..cb92aa21d --- /dev/null +++ b/pkg/accessreview/drivers/mercury.go @@ -0,0 +1,173 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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{} + } +} diff --git a/pkg/accessreview/drivers/mercury_test.go b/pkg/accessreview/drivers/mercury_test.go new file mode 100644 index 000000000..cec6d6f7b --- /dev/null +++ b/pkg/accessreview/drivers/mercury_test.go @@ -0,0 +1,78 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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)) + }) + } +} diff --git a/pkg/accessreview/drivers/testdata/apollo.yaml b/pkg/accessreview/drivers/testdata/apollo.yaml new file mode 100644 index 000000000..179de141f --- /dev/null +++ b/pkg/accessreview/drivers/testdata/apollo.yaml @@ -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 diff --git a/pkg/accessreview/drivers/testdata/clickhouse.yaml b/pkg/accessreview/drivers/testdata/clickhouse.yaml new file mode 100644 index 000000000..a6f3b8ada --- /dev/null +++ b/pkg/accessreview/drivers/testdata/clickhouse.yaml @@ -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 diff --git a/pkg/accessreview/drivers/testdata/deepgram.yaml b/pkg/accessreview/drivers/testdata/deepgram.yaml new file mode 100644 index 000000000..f39323305 --- /dev/null +++ b/pkg/accessreview/drivers/testdata/deepgram.yaml @@ -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 diff --git a/pkg/accessreview/drivers/testdata/langfuse.yaml b/pkg/accessreview/drivers/testdata/langfuse.yaml new file mode 100644 index 000000000..2583bdf9a --- /dev/null +++ b/pkg/accessreview/drivers/testdata/langfuse.yaml @@ -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 diff --git a/pkg/accessreview/drivers/testdata/mercury.yaml b/pkg/accessreview/drivers/testdata/mercury.yaml new file mode 100644 index 000000000..a4e298b89 --- /dev/null +++ b/pkg/accessreview/drivers/testdata/mercury.yaml @@ -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 diff --git a/pkg/accessreview/drivers/vcr_test.go b/pkg/accessreview/drivers/vcr_test.go index e66b6f3c1..24f64370e 100644 --- a/pkg/accessreview/drivers/vcr_test.go +++ b/pkg/accessreview/drivers/vcr_test.go @@ -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()"), 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. diff --git a/pkg/connector/provider/apollo.go b/pkg/connector/provider/apollo.go new file mode 100644 index 000000000..5ec9a622e --- /dev/null +++ b/pkg/connector/provider/apollo.go @@ -0,0 +1,51 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 + }, + } +} diff --git a/pkg/connector/provider/builtin.go b/pkg/connector/provider/builtin.go index b2bf5b8bf..bc140fff4 100644 --- a/pkg/connector/provider/builtin.go +++ b/pkg/connector/provider/builtin.go @@ -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(), diff --git a/pkg/connector/provider/clickhouse.go b/pkg/connector/provider/clickhouse.go new file mode 100644 index 000000000..3fc02737f --- /dev/null +++ b/pkg/connector/provider/clickhouse.go @@ -0,0 +1,52 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 + }, + } +} diff --git a/pkg/connector/provider/deepgram.go b/pkg/connector/provider/deepgram.go new file mode 100644 index 000000000..97772bed7 --- /dev/null +++ b/pkg/connector/provider/deepgram.go @@ -0,0 +1,49 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 `), 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 + }, + } +} diff --git a/pkg/connector/provider/langfuse.go b/pkg/connector/provider/langfuse.go new file mode 100644 index 000000000..0bb8f7a8d --- /dev/null +++ b/pkg/connector/provider/langfuse.go @@ -0,0 +1,89 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 +} diff --git a/pkg/connector/provider/langfuse_test.go b/pkg/connector/provider/langfuse_test.go new file mode 100644 index 000000000..6034e3252 --- /dev/null +++ b/pkg/connector/provider/langfuse_test.go @@ -0,0 +1,108 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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") + }) +} diff --git a/pkg/connector/provider/mercury.go b/pkg/connector/provider/mercury.go new file mode 100644 index 000000000..3d3837d7a --- /dev/null +++ b/pkg/connector/provider/mercury.go @@ -0,0 +1,51 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 + }, + } +} diff --git a/pkg/connector/provider/probe.go b/pkg/connector/provider/probe.go index 537e08506..816eb51aa 100644 --- a/pkg/connector/provider/probe.go +++ b/pkg/connector/provider/probe.go @@ -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 { diff --git a/pkg/connector/provider/probe_test.go b/pkg/connector/provider/probe_test.go index 7b030c398..257199988 100644 --- a/pkg/connector/provider/probe_test.go +++ b/pkg/connector/provider/probe_test.go @@ -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() diff --git a/pkg/coredata/connector_provider.go b/pkg/coredata/connector_provider.go index 5bfafc27c..2d3fd561b 100644 --- a/pkg/coredata/connector_provider.go +++ b/pkg/coredata/connector_provider.go @@ -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 } diff --git a/pkg/coredata/connector_settings.go b/pkg/coredata/connector_settings.go index 9fb123be3..fe5bf73fb 100644 --- a/pkg/coredata/connector_settings.go +++ b/pkg/coredata/connector_settings.go @@ -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 diff --git a/pkg/coredata/migrations/20260612T263841Z.sql b/pkg/coredata/migrations/20260612T263841Z.sql new file mode 100644 index 000000000..db5f0fe45 --- /dev/null +++ b/pkg/coredata/migrations/20260612T263841Z.sql @@ -0,0 +1,15 @@ +-- Copyright (c) 2026 Probo Inc . +-- +-- 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'; diff --git a/pkg/coredata/migrations/20260612T318094Z.sql b/pkg/coredata/migrations/20260612T318094Z.sql new file mode 100644 index 000000000..c388fbefd --- /dev/null +++ b/pkg/coredata/migrations/20260612T318094Z.sql @@ -0,0 +1,15 @@ +-- Copyright (c) 2026 Probo Inc . +-- +-- 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'; diff --git a/pkg/coredata/migrations/20260612T489176Z.sql b/pkg/coredata/migrations/20260612T489176Z.sql new file mode 100644 index 000000000..e89c7088c --- /dev/null +++ b/pkg/coredata/migrations/20260612T489176Z.sql @@ -0,0 +1,15 @@ +-- Copyright (c) 2026 Probo Inc . +-- +-- 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'; diff --git a/pkg/coredata/migrations/20260612T642517Z.sql b/pkg/coredata/migrations/20260612T642517Z.sql new file mode 100644 index 000000000..ff34a4c16 --- /dev/null +++ b/pkg/coredata/migrations/20260612T642517Z.sql @@ -0,0 +1,15 @@ +-- Copyright (c) 2026 Probo Inc . +-- +-- 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'; diff --git a/pkg/coredata/migrations/20260612T705263Z.sql b/pkg/coredata/migrations/20260612T705263Z.sql new file mode 100644 index 000000000..c425707d9 --- /dev/null +++ b/pkg/coredata/migrations/20260612T705263Z.sql @@ -0,0 +1,15 @@ +-- Copyright (c) 2026 Probo Inc . +-- +-- 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'; diff --git a/pkg/server/api/console/v1/connector_settings.go b/pkg/server/api/console/v1/connector_settings.go index c3e37b886..08d10dd89 100644 --- a/pkg/server/api/console/v1/connector_settings.go +++ b/pkg/server/api/console/v1/connector_settings.go @@ -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 diff --git a/pkg/server/api/console/v1/graphql/connector.graphql b/pkg/server/api/console/v1/graphql/connector.graphql index 9b007c298..ac8adc3fc 100644 --- a/pkg/server/api/console/v1/graphql/connector.graphql +++ b/pkg/server/api/console/v1/graphql/connector.graphql @@ -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 {