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:
13
packages/ui/src/Atoms/ThirdParties/Apollo.tsx
Normal file
13
packages/ui/src/Atoms/ThirdParties/Apollo.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
9
packages/ui/src/Atoms/ThirdParties/ClickHouse.tsx
Normal file
9
packages/ui/src/Atoms/ThirdParties/ClickHouse.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
9
packages/ui/src/Atoms/ThirdParties/Deepgram.tsx
Normal file
9
packages/ui/src/Atoms/ThirdParties/Deepgram.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
15
packages/ui/src/Atoms/ThirdParties/Langfuse.tsx
Normal file
15
packages/ui/src/Atoms/ThirdParties/Langfuse.tsx
Normal file
File diff suppressed because one or more lines are too long
9
packages/ui/src/Atoms/ThirdParties/Mercury.tsx
Normal file
9
packages/ui/src/Atoms/ThirdParties/Mercury.tsx
Normal file
File diff suppressed because one or more lines are too long
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
196
pkg/accessreview/drivers/apollo.go
Normal file
196
pkg/accessreview/drivers/apollo.go
Normal file
@@ -0,0 +1,196 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package drivers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"go.probo.inc/probo/pkg/coredata"
|
||||
)
|
||||
|
||||
const (
|
||||
apolloUsersEndpoint = "https://api.apollo.io/api/v1/users/search"
|
||||
apolloUsersPageSize = 100
|
||||
)
|
||||
|
||||
// ApolloDriver lists the teammates (seats) of a single Apollo.io account.
|
||||
// The master API key is bound to one account, so GET /api/v1/users/search
|
||||
// returns every teammate of that account. The key is presented in the
|
||||
// x-api-key header by the connection transport, not here.
|
||||
type ApolloDriver struct {
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
var _ Driver = (*ApolloDriver)(nil)
|
||||
|
||||
type apolloUser struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
FirstName string `json:"first_name"`
|
||||
LastName string `json:"last_name"`
|
||||
Email string `json:"email"`
|
||||
// Role is Apollo's permission-profile name (e.g. "Admin", "Billing and
|
||||
// Seat Manager"). It is decoded as RawMessage and read via apolloRole so
|
||||
// a non-string shape (object/null on some plans) degrades to an empty
|
||||
// role instead of failing the decode of the whole page.
|
||||
Role json.RawMessage `json:"role"`
|
||||
}
|
||||
|
||||
type apolloUsersResponse struct {
|
||||
Users []apolloUser `json:"users"`
|
||||
Pagination struct {
|
||||
Page int `json:"page"`
|
||||
PerPage int `json:"per_page"`
|
||||
TotalPages int `json:"total_pages"`
|
||||
TotalEntries int `json:"total_entries"`
|
||||
} `json:"pagination"`
|
||||
}
|
||||
|
||||
func NewApolloDriver(httpClient *http.Client) *ApolloDriver {
|
||||
return &ApolloDriver{httpClient: httpClient}
|
||||
}
|
||||
|
||||
func (d *ApolloDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error) {
|
||||
var records []AccountRecord
|
||||
|
||||
for page := 1; page <= maxPaginationPages; page++ {
|
||||
resp, err := d.fetchUsersPage(ctx, page)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, u := range resp.Users {
|
||||
email := strings.TrimSpace(u.Email)
|
||||
if email == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
role := apolloRole(u.Role)
|
||||
|
||||
records = append(records, AccountRecord{
|
||||
Email: email,
|
||||
FullName: apolloFullName(u, email),
|
||||
Roles: apolloRoles(role),
|
||||
IsAdmin: apolloIsAdmin(role),
|
||||
MFAStatus: coredata.MFAStatusUnknown,
|
||||
AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown,
|
||||
AccountType: coredata.AccessReviewEntryAccountTypeUser,
|
||||
ExternalID: strings.TrimSpace(u.ID),
|
||||
})
|
||||
}
|
||||
|
||||
if resp.Pagination.TotalPages <= page || len(resp.Users) == 0 {
|
||||
return records, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("cannot list all apollo users: %w", ErrPaginationLimitReached)
|
||||
}
|
||||
|
||||
func (d *ApolloDriver) fetchUsersPage(ctx context.Context, page int) (*apolloUsersResponse, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, apolloUsersEndpoint, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot create apollo users request: %w", err)
|
||||
}
|
||||
|
||||
q := req.URL.Query()
|
||||
q.Set("page", strconv.Itoa(page))
|
||||
q.Set("per_page", strconv.Itoa(apolloUsersPageSize))
|
||||
req.URL.RawQuery = q.Encode()
|
||||
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
httpResp, err := d.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot execute apollo users request: %w", err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
_ = httpResp.Body.Close()
|
||||
}()
|
||||
|
||||
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("cannot fetch apollo users: unexpected status %d", httpResp.StatusCode)
|
||||
}
|
||||
|
||||
var resp apolloUsersResponse
|
||||
if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil {
|
||||
return nil, fmt.Errorf("cannot decode apollo users response: %w", err)
|
||||
}
|
||||
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
func apolloFullName(u apolloUser, fallback string) string {
|
||||
if name := strings.TrimSpace(u.Name); name != "" {
|
||||
return name
|
||||
}
|
||||
|
||||
combined := strings.TrimSpace(strings.TrimSpace(u.FirstName) + " " + strings.TrimSpace(u.LastName))
|
||||
if combined != "" {
|
||||
return combined
|
||||
}
|
||||
|
||||
return fallback
|
||||
}
|
||||
|
||||
// apolloRole extracts a role label from Apollo's `role` field, which is
|
||||
// normally a plain string. It also tolerates a `{"name": ...}` object and a
|
||||
// null/absent value so an unexpected shape on some plans yields an empty
|
||||
// role rather than failing the whole page decode.
|
||||
func apolloRole(raw json.RawMessage) string {
|
||||
if len(raw) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
var s string
|
||||
if err := json.Unmarshal(raw, &s); err == nil {
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
|
||||
var obj struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &obj); err == nil {
|
||||
return strings.TrimSpace(obj.Name)
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// apolloRoles wraps a single Apollo permission-profile name as the account's
|
||||
// role set, returning an empty slice when no profile is present.
|
||||
func apolloRoles(role string) []string {
|
||||
if role == "" {
|
||||
return []string{}
|
||||
}
|
||||
|
||||
return []string{role}
|
||||
}
|
||||
|
||||
// apolloIsAdmin reports whether a permission-profile name is Apollo's
|
||||
// built-in super-admin profile ("Admin"). Apollo exposes no boolean admin
|
||||
// flag and lets customers name custom profiles freely, so the match is exact
|
||||
// (case-insensitive), not a substring: a profile merely containing "admin"
|
||||
// (e.g. "Billing Admin") is not auto-classified — its Role is still surfaced
|
||||
// for the reviewer to judge.
|
||||
func apolloIsAdmin(role string) bool {
|
||||
return strings.EqualFold(strings.TrimSpace(role), "Admin")
|
||||
}
|
||||
70
pkg/accessreview/drivers/apollo_test.go
Normal file
70
pkg/accessreview/drivers/apollo_test.go
Normal file
@@ -0,0 +1,70 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package drivers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.probo.inc/probo/pkg/coredata"
|
||||
)
|
||||
|
||||
func TestApolloDriver(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rec := newRecorder(t, "testdata/apollo", "APOLLO_API_KEY")
|
||||
// Apollo authenticates via the x-api-key header, not Authorization.
|
||||
client := newVCRClientWithHeader(rec, "x-api-key", os.Getenv("APOLLO_API_KEY"))
|
||||
|
||||
driver := NewApolloDriver(client)
|
||||
records, err := driver.ListAccounts(context.Background())
|
||||
require.NoError(t, err)
|
||||
require.Len(t, records, 4)
|
||||
|
||||
admin := records[0]
|
||||
assert.Equal(t, "5f0000000000000000000001", admin.ExternalID)
|
||||
assert.Equal(t, "alice@example.com", admin.Email)
|
||||
assert.Equal(t, "Alice Admin", admin.FullName)
|
||||
assert.Equal(t, []string{"Admin"}, admin.Roles)
|
||||
assert.True(t, admin.IsAdmin)
|
||||
assert.Equal(t, coredata.AccessReviewEntryAccountTypeUser, admin.AccountType)
|
||||
|
||||
rep := records[1]
|
||||
assert.Equal(t, []string{"Sales Rep"}, rep.Roles)
|
||||
assert.False(t, rep.IsAdmin)
|
||||
|
||||
manager := records[2]
|
||||
assert.Equal(t, []string{"Billing and Seat Manager"}, manager.Roles)
|
||||
assert.False(t, manager.IsAdmin)
|
||||
|
||||
// No name and no first/last: the display name falls back to the email.
|
||||
noName := records[3]
|
||||
assert.Equal(t, "dave@example.com", noName.Email)
|
||||
assert.Equal(t, "dave@example.com", noName.FullName)
|
||||
}
|
||||
|
||||
func TestApolloIsAdmin(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
assert.True(t, apolloIsAdmin("Admin"))
|
||||
assert.True(t, apolloIsAdmin("admin"))
|
||||
// Exact match only: profiles that merely contain "admin" are not admins.
|
||||
assert.False(t, apolloIsAdmin("Master Admin"))
|
||||
assert.False(t, apolloIsAdmin("Billing Admin"))
|
||||
assert.False(t, apolloIsAdmin("Sales Rep"))
|
||||
}
|
||||
224
pkg/accessreview/drivers/clickhouse.go
Normal file
224
pkg/accessreview/drivers/clickhouse.go
Normal file
@@ -0,0 +1,224 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package drivers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.probo.inc/probo/pkg/coredata"
|
||||
)
|
||||
|
||||
const clickhouseAPIBaseURL = "https://api.clickhouse.cloud"
|
||||
|
||||
// ClickHouseDriver lists the members of a single ClickHouse Cloud
|
||||
// organization. A key/secret pair (HTTP Basic) is scoped to exactly one
|
||||
// organization, so the driver discovers that organization via
|
||||
// GET /v1/organizations and then lists its members — no org ID needs to be
|
||||
// configured. The Basic credential is applied by the connection transport.
|
||||
type ClickHouseDriver struct {
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
var _ Driver = (*ClickHouseDriver)(nil)
|
||||
|
||||
type clickhouseOrgsResponse struct {
|
||||
Result []struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
} `json:"result"`
|
||||
}
|
||||
|
||||
type clickhouseMembersResponse struct {
|
||||
Result []clickhouseMember `json:"result"`
|
||||
}
|
||||
|
||||
type clickhouseMember struct {
|
||||
UserID string `json:"userId"`
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
Role string `json:"role"`
|
||||
JoinedAt string `json:"joinedAt"`
|
||||
AssignedRoles []struct {
|
||||
RoleName string `json:"roleName"`
|
||||
} `json:"assignedRoles"`
|
||||
}
|
||||
|
||||
func NewClickHouseDriver(httpClient *http.Client) *ClickHouseDriver {
|
||||
return &ClickHouseDriver{httpClient: httpClient}
|
||||
}
|
||||
|
||||
func (d *ClickHouseDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error) {
|
||||
organizationID, err := d.resolveOrganizationID(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
members, err := d.fetchMembers(ctx, organizationID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
records := make([]AccountRecord, 0, len(members))
|
||||
|
||||
for _, m := range members {
|
||||
email := strings.TrimSpace(m.Email)
|
||||
if email == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
record := AccountRecord{
|
||||
Email: email,
|
||||
FullName: clickhouseFullName(m, email),
|
||||
Roles: clickhouseRoles(m),
|
||||
IsAdmin: clickhouseIsAdmin(m),
|
||||
MFAStatus: coredata.MFAStatusUnknown,
|
||||
AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown,
|
||||
AccountType: coredata.AccessReviewEntryAccountTypeUser,
|
||||
ExternalID: strings.TrimSpace(m.UserID),
|
||||
}
|
||||
|
||||
if m.JoinedAt != "" {
|
||||
if t, err := time.Parse(time.RFC3339, m.JoinedAt); err == nil {
|
||||
record.CreatedAt = &t
|
||||
}
|
||||
}
|
||||
|
||||
records = append(records, record)
|
||||
}
|
||||
|
||||
return records, nil
|
||||
}
|
||||
|
||||
func (d *ClickHouseDriver) resolveOrganizationID(ctx context.Context) (string, error) {
|
||||
endpoint, err := url.JoinPath(clickhouseAPIBaseURL, "v1", "organizations")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot build clickhouse organizations URL: %w", err)
|
||||
}
|
||||
|
||||
var resp clickhouseOrgsResponse
|
||||
if err := d.getJSON(ctx, endpoint, "clickhouse organizations", &resp); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if len(resp.Result) == 0 || strings.TrimSpace(resp.Result[0].ID) == "" {
|
||||
return "", fmt.Errorf("cannot determine clickhouse organization: API key is not associated with any organization")
|
||||
}
|
||||
|
||||
return strings.TrimSpace(resp.Result[0].ID), nil
|
||||
}
|
||||
|
||||
func (d *ClickHouseDriver) fetchMembers(ctx context.Context, organizationID string) ([]clickhouseMember, error) {
|
||||
endpoint, err := url.JoinPath(clickhouseAPIBaseURL, "v1", "organizations", url.PathEscape(organizationID), "members")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot build clickhouse members URL: %w", err)
|
||||
}
|
||||
|
||||
var resp clickhouseMembersResponse
|
||||
if err := d.getJSON(ctx, endpoint, "clickhouse members", &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return resp.Result, nil
|
||||
}
|
||||
|
||||
func (d *ClickHouseDriver) getJSON(ctx context.Context, endpoint, what string, out any) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot create %s request: %w", what, err)
|
||||
}
|
||||
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
httpResp, err := d.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot execute %s request: %w", what, err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
_ = httpResp.Body.Close()
|
||||
}()
|
||||
|
||||
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
|
||||
return fmt.Errorf("cannot fetch %s: unexpected status %d", what, httpResp.StatusCode)
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(httpResp.Body).Decode(out); err != nil {
|
||||
return fmt.Errorf("cannot decode %s response: %w", what, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func clickhouseFullName(m clickhouseMember, fallback string) string {
|
||||
if name := strings.TrimSpace(m.Name); name != "" {
|
||||
return name
|
||||
}
|
||||
|
||||
return fallback
|
||||
}
|
||||
|
||||
// clickhouseRoles prefers the custom/system roles in assignedRoles (the live
|
||||
// source of truth) and falls back to the deprecated `role` field, which is
|
||||
// frozen for organizations migrated to custom roles.
|
||||
func clickhouseRoles(m clickhouseMember) []string {
|
||||
names := make([]string, 0, len(m.AssignedRoles))
|
||||
|
||||
for _, r := range m.AssignedRoles {
|
||||
if name := strings.TrimSpace(r.RoleName); name != "" {
|
||||
names = append(names, name)
|
||||
}
|
||||
}
|
||||
|
||||
if len(names) > 0 {
|
||||
return names
|
||||
}
|
||||
|
||||
switch strings.ToLower(strings.TrimSpace(m.Role)) {
|
||||
case "admin":
|
||||
return []string{"Admin"}
|
||||
case "developer":
|
||||
return []string{"Developer"}
|
||||
default:
|
||||
if r := strings.TrimSpace(m.Role); r != "" {
|
||||
return []string{r}
|
||||
}
|
||||
|
||||
return []string{}
|
||||
}
|
||||
}
|
||||
|
||||
func clickhouseIsAdmin(m clickhouseMember) bool {
|
||||
// assignedRoles is the live source of truth. The deprecated role field is
|
||||
// frozen at its pre-migration value for organizations that moved to custom
|
||||
// roles, so consult it only when there are no assigned roles — otherwise a
|
||||
// stale "admin" could misclassify a since-demoted member.
|
||||
if len(m.AssignedRoles) > 0 {
|
||||
for _, r := range m.AssignedRoles {
|
||||
if strings.EqualFold(strings.TrimSpace(r.RoleName), "admin") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
return strings.EqualFold(strings.TrimSpace(m.Role), "admin")
|
||||
}
|
||||
59
pkg/accessreview/drivers/clickhouse_test.go
Normal file
59
pkg/accessreview/drivers/clickhouse_test.go
Normal file
@@ -0,0 +1,59 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package drivers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.probo.inc/probo/pkg/coredata"
|
||||
)
|
||||
|
||||
func TestClickHouseDriver(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rec := newRecorder(t, "testdata/clickhouse", "CLICKHOUSE_API_KEY")
|
||||
// ClickHouse Cloud authenticates with HTTP Basic (keyId:keySecret). The
|
||||
// matcher ignores Authorization, so replay needs no auth.
|
||||
client := newVCRClient(rec, basicAuthUserPass(os.Getenv("CLICKHOUSE_API_KEY")))
|
||||
|
||||
driver := NewClickHouseDriver(client)
|
||||
records, err := driver.ListAccounts(context.Background())
|
||||
require.NoError(t, err)
|
||||
require.Len(t, records, 3)
|
||||
|
||||
admin := records[0]
|
||||
assert.Equal(t, "u-0000-0001", admin.ExternalID)
|
||||
assert.Equal(t, "admin@example.com", admin.Email)
|
||||
assert.Equal(t, "Admin User", admin.FullName)
|
||||
assert.Equal(t, []string{"Admin"}, admin.Roles)
|
||||
assert.True(t, admin.IsAdmin)
|
||||
assert.NotNil(t, admin.CreatedAt)
|
||||
assert.Equal(t, coredata.AccessReviewEntryAccountTypeUser, admin.AccountType)
|
||||
|
||||
dev := records[1]
|
||||
assert.Equal(t, []string{"Developer"}, dev.Roles)
|
||||
assert.False(t, dev.IsAdmin)
|
||||
|
||||
// assignedRoles wins over the deprecated role field; a custom role
|
||||
// merely containing "Admin" is not promoted to admin.
|
||||
custom := records[2]
|
||||
assert.Equal(t, "custom@example.com", custom.FullName)
|
||||
assert.Equal(t, []string{"Billing Admin"}, custom.Roles)
|
||||
assert.False(t, custom.IsAdmin)
|
||||
}
|
||||
244
pkg/accessreview/drivers/deepgram.go
Normal file
244
pkg/accessreview/drivers/deepgram.go
Normal file
@@ -0,0 +1,244 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package drivers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"go.probo.inc/probo/pkg/coredata"
|
||||
)
|
||||
|
||||
const deepgramAPIBaseURL = "https://api.deepgram.com"
|
||||
|
||||
// DeepgramDriver lists the members of every Deepgram project the API key
|
||||
// can access. The key (presented in the `Authorization: Token <key>`
|
||||
// scheme by the connection transport) is scoped to a single account, whose
|
||||
// members may span several projects; the driver aggregates them and dedupes
|
||||
// by member_id, unioning each member's per-project scopes.
|
||||
type DeepgramDriver struct {
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
var _ Driver = (*DeepgramDriver)(nil)
|
||||
|
||||
type deepgramProject struct {
|
||||
ProjectID string `json:"project_id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type deepgramProjectsResponse struct {
|
||||
Projects []deepgramProject `json:"projects"`
|
||||
}
|
||||
|
||||
type deepgramMember struct {
|
||||
MemberID string `json:"member_id"`
|
||||
Email string `json:"email"`
|
||||
FirstName string `json:"first_name"`
|
||||
LastName string `json:"last_name"`
|
||||
Scopes []string `json:"scopes"`
|
||||
}
|
||||
|
||||
type deepgramMembersResponse struct {
|
||||
Members []deepgramMember `json:"members"`
|
||||
}
|
||||
|
||||
func NewDeepgramDriver(httpClient *http.Client) *DeepgramDriver {
|
||||
return &DeepgramDriver{httpClient: httpClient}
|
||||
}
|
||||
|
||||
func (d *DeepgramDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error) {
|
||||
projects, err := d.fetchProjects(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Aggregate members across projects, preserving first-seen order and
|
||||
// unioning the scopes a member holds in each project.
|
||||
order := make([]string, 0)
|
||||
merged := make(map[string]*deepgramMember)
|
||||
|
||||
for _, project := range projects {
|
||||
members, err := d.fetchProjectMembers(ctx, project.ProjectID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, m := range members {
|
||||
existing, ok := merged[m.MemberID]
|
||||
if !ok {
|
||||
copied := m
|
||||
merged[m.MemberID] = &copied
|
||||
order = append(order, m.MemberID)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
existing.Scopes = deepgramUnionScopes(existing.Scopes, m.Scopes)
|
||||
}
|
||||
}
|
||||
|
||||
records := make([]AccountRecord, 0, len(order))
|
||||
|
||||
for _, id := range order {
|
||||
m := merged[id]
|
||||
|
||||
email := strings.TrimSpace(m.Email)
|
||||
if email == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
records = append(records, AccountRecord{
|
||||
Email: email,
|
||||
FullName: deepgramFullName(*m, email),
|
||||
Roles: deepgramRoles(m.Scopes),
|
||||
IsAdmin: deepgramIsAdmin(m.Scopes),
|
||||
MFAStatus: coredata.MFAStatusUnknown,
|
||||
AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown,
|
||||
AccountType: coredata.AccessReviewEntryAccountTypeUser,
|
||||
ExternalID: strings.TrimSpace(m.MemberID),
|
||||
})
|
||||
}
|
||||
|
||||
return records, nil
|
||||
}
|
||||
|
||||
func (d *DeepgramDriver) fetchProjects(ctx context.Context) ([]deepgramProject, error) {
|
||||
endpoint, err := url.JoinPath(deepgramAPIBaseURL, "v1", "projects")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot build deepgram projects URL: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot create deepgram projects request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
httpResp, err := d.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot execute deepgram projects request: %w", err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
_ = httpResp.Body.Close()
|
||||
}()
|
||||
|
||||
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("cannot fetch deepgram projects: unexpected status %d", httpResp.StatusCode)
|
||||
}
|
||||
|
||||
var resp deepgramProjectsResponse
|
||||
if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil {
|
||||
return nil, fmt.Errorf("cannot decode deepgram projects response: %w", err)
|
||||
}
|
||||
|
||||
return resp.Projects, nil
|
||||
}
|
||||
|
||||
func (d *DeepgramDriver) fetchProjectMembers(ctx context.Context, projectID string) ([]deepgramMember, error) {
|
||||
endpoint, err := url.JoinPath(deepgramAPIBaseURL, "v1", "projects", url.PathEscape(projectID), "members")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot build deepgram members URL: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot create deepgram members request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
httpResp, err := d.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot execute deepgram members request: %w", err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
_ = httpResp.Body.Close()
|
||||
}()
|
||||
|
||||
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("cannot fetch deepgram members: unexpected status %d", httpResp.StatusCode)
|
||||
}
|
||||
|
||||
var resp deepgramMembersResponse
|
||||
if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil {
|
||||
return nil, fmt.Errorf("cannot decode deepgram members response: %w", err)
|
||||
}
|
||||
|
||||
return resp.Members, nil
|
||||
}
|
||||
|
||||
func deepgramFullName(m deepgramMember, fallback string) string {
|
||||
fullName := strings.TrimSpace(strings.TrimSpace(m.FirstName) + " " + strings.TrimSpace(m.LastName))
|
||||
if fullName != "" {
|
||||
return fullName
|
||||
}
|
||||
|
||||
return fallback
|
||||
}
|
||||
|
||||
func deepgramUnionScopes(a, b []string) []string {
|
||||
seen := make(map[string]bool, len(a))
|
||||
out := make([]string, 0, len(a)+len(b))
|
||||
|
||||
for _, scopes := range [][]string{a, b} {
|
||||
for _, s := range scopes {
|
||||
if seen[s] {
|
||||
continue
|
||||
}
|
||||
|
||||
seen[s] = true
|
||||
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
// deepgramRoles derives a role label from a member's scopes. Deepgram has no
|
||||
// dedicated role field; ownership/administration is expressed through the
|
||||
// scope list. Unknown scope sets fall back to "Member".
|
||||
func deepgramRoles(scopes []string) []string {
|
||||
switch {
|
||||
case deepgramHasScope(scopes, "owner"):
|
||||
return []string{"Owner"}
|
||||
case deepgramHasScope(scopes, "admin"):
|
||||
return []string{"Admin"}
|
||||
default:
|
||||
return []string{"Member"}
|
||||
}
|
||||
}
|
||||
|
||||
func deepgramIsAdmin(scopes []string) bool {
|
||||
return deepgramHasScope(scopes, "owner") || deepgramHasScope(scopes, "admin")
|
||||
}
|
||||
|
||||
func deepgramHasScope(scopes []string, want string) bool {
|
||||
for _, s := range scopes {
|
||||
if strings.EqualFold(strings.TrimSpace(s), want) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
85
pkg/accessreview/drivers/deepgram_test.go
Normal file
85
pkg/accessreview/drivers/deepgram_test.go
Normal file
@@ -0,0 +1,85 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package drivers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.probo.inc/probo/pkg/coredata"
|
||||
)
|
||||
|
||||
func TestDeepgramDriver(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rec := newRecorder(t, "testdata/deepgram", "DEEPGRAM_API_KEY")
|
||||
// Deepgram authenticates with the `Token` scheme. The matcher ignores
|
||||
// Authorization, so replay needs no auth; the value matters only when
|
||||
// re-recording.
|
||||
auth := ""
|
||||
if token := os.Getenv("DEEPGRAM_API_KEY"); token != "" {
|
||||
auth = "Token " + token
|
||||
}
|
||||
|
||||
client := newVCRClient(rec, auth)
|
||||
|
||||
driver := NewDeepgramDriver(client)
|
||||
records, err := driver.ListAccounts(context.Background())
|
||||
require.NoError(t, err)
|
||||
// owner@example.com appears in both projects and must be deduped.
|
||||
require.Len(t, records, 3)
|
||||
|
||||
// owner@example.com is first seen with only ["member"] in project-1 and
|
||||
// gains ["owner"] in project-2. The Owner role / admin flag therefore
|
||||
// depend on the cross-project scope union actually taking effect.
|
||||
owner := records[0]
|
||||
assert.Equal(t, "m-0000-0000-0001", owner.ExternalID)
|
||||
assert.Equal(t, "owner@example.com", owner.Email)
|
||||
assert.Equal(t, "Olivia Owner", owner.FullName)
|
||||
assert.Equal(t, []string{"Owner"}, owner.Roles)
|
||||
assert.True(t, owner.IsAdmin)
|
||||
assert.Equal(t, coredata.AccessReviewEntryAccountTypeUser, owner.AccountType)
|
||||
|
||||
member := records[1]
|
||||
assert.Equal(t, "member@example.com", member.Email)
|
||||
assert.Equal(t, []string{"Member"}, member.Roles)
|
||||
assert.False(t, member.IsAdmin)
|
||||
|
||||
dev := records[2]
|
||||
assert.Equal(t, "dev@example.com", dev.Email)
|
||||
assert.Equal(t, []string{"Member"}, dev.Roles)
|
||||
}
|
||||
|
||||
func TestDeepgramRoles(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
assert.Equal(t, []string{"Owner"}, deepgramRoles([]string{"owner"}))
|
||||
assert.Equal(t, []string{"Admin"}, deepgramRoles([]string{"admin", "read:transcripts"}))
|
||||
assert.Equal(t, []string{"Member"}, deepgramRoles([]string{"read:transcripts"}))
|
||||
assert.True(t, deepgramIsAdmin([]string{"owner"}))
|
||||
assert.True(t, deepgramIsAdmin([]string{"admin"}))
|
||||
assert.False(t, deepgramIsAdmin([]string{"member"}))
|
||||
}
|
||||
|
||||
func TestDeepgramUnionScopes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
assert.Equal(t, []string{"member", "owner"}, deepgramUnionScopes([]string{"member"}, []string{"owner"}))
|
||||
assert.Equal(t, []string{"a", "b"}, deepgramUnionScopes([]string{"a", "b"}, []string{"a"}))
|
||||
assert.Empty(t, deepgramUnionScopes(nil, nil))
|
||||
}
|
||||
154
pkg/accessreview/drivers/langfuse.go
Normal file
154
pkg/accessreview/drivers/langfuse.go
Normal file
@@ -0,0 +1,154 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package drivers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"go.probo.inc/probo/pkg/coredata"
|
||||
)
|
||||
|
||||
// LangfuseDriver lists the members of a single Langfuse organization via the
|
||||
// organization-scoped public API. The organization API key (HTTP Basic,
|
||||
// publicKey:secretKey) is bound to one organization on the configured host,
|
||||
// so GET /api/public/organizations/memberships returns every member with no
|
||||
// tenant selector. The Basic credential is applied by the connection
|
||||
// transport; the base URL spans the regional cloud hosts and self-hosting.
|
||||
type LangfuseDriver struct {
|
||||
httpClient *http.Client
|
||||
baseURL string
|
||||
}
|
||||
|
||||
var _ Driver = (*LangfuseDriver)(nil)
|
||||
|
||||
type langfuseMembershipsResponse struct {
|
||||
Memberships []langfuseMembership `json:"memberships"`
|
||||
}
|
||||
|
||||
type langfuseMembership struct {
|
||||
UserID string `json:"userId"`
|
||||
Role string `json:"role"`
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
func NewLangfuseDriver(httpClient *http.Client, baseURL string) *LangfuseDriver {
|
||||
return &LangfuseDriver{
|
||||
httpClient: httpClient,
|
||||
baseURL: baseURL,
|
||||
}
|
||||
}
|
||||
|
||||
func (d *LangfuseDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error) {
|
||||
baseURL, err := url.Parse(d.baseURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot parse langfuse base URL: %w", err)
|
||||
}
|
||||
|
||||
endpoint := baseURL.JoinPath("api", "public", "organizations", "memberships")
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot create langfuse memberships request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
httpResp, err := d.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot execute langfuse memberships request: %w", err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
_ = httpResp.Body.Close()
|
||||
}()
|
||||
|
||||
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("cannot fetch langfuse memberships: unexpected status %d", httpResp.StatusCode)
|
||||
}
|
||||
|
||||
var resp langfuseMembershipsResponse
|
||||
if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil {
|
||||
return nil, fmt.Errorf("cannot decode langfuse memberships response: %w", err)
|
||||
}
|
||||
|
||||
records := make([]AccountRecord, 0, len(resp.Memberships))
|
||||
|
||||
for _, m := range resp.Memberships {
|
||||
email := strings.TrimSpace(m.Email)
|
||||
if email == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
records = append(records, AccountRecord{
|
||||
Email: email,
|
||||
FullName: langfuseFullName(m, email),
|
||||
Roles: langfuseRoles(m.Role),
|
||||
IsAdmin: langfuseIsAdmin(m.Role),
|
||||
MFAStatus: coredata.MFAStatusUnknown,
|
||||
AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown,
|
||||
AccountType: coredata.AccessReviewEntryAccountTypeUser,
|
||||
ExternalID: strings.TrimSpace(m.UserID),
|
||||
})
|
||||
}
|
||||
|
||||
return records, nil
|
||||
}
|
||||
|
||||
func langfuseFullName(m langfuseMembership, fallback string) string {
|
||||
if name := strings.TrimSpace(m.Name); name != "" {
|
||||
return name
|
||||
}
|
||||
|
||||
return fallback
|
||||
}
|
||||
|
||||
// langfuseRoles maps the Langfuse organization MembershipRole
|
||||
// (OWNER/ADMIN/MEMBER/VIEWER, and the RBAC NONE) to a stable display label,
|
||||
// preserving unknown future roles verbatim.
|
||||
func langfuseRoles(role string) []string {
|
||||
switch strings.ToUpper(strings.TrimSpace(role)) {
|
||||
case "OWNER":
|
||||
return []string{"Owner"}
|
||||
case "ADMIN":
|
||||
return []string{"Admin"}
|
||||
case "MEMBER":
|
||||
return []string{"Member"}
|
||||
case "VIEWER":
|
||||
return []string{"Viewer"}
|
||||
case "NONE":
|
||||
return []string{"None"}
|
||||
default:
|
||||
if r := strings.TrimSpace(role); r != "" {
|
||||
return []string{r}
|
||||
}
|
||||
|
||||
return []string{}
|
||||
}
|
||||
}
|
||||
|
||||
func langfuseIsAdmin(role string) bool {
|
||||
switch strings.ToUpper(strings.TrimSpace(role)) {
|
||||
case "OWNER", "ADMIN":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
74
pkg/accessreview/drivers/langfuse_test.go
Normal file
74
pkg/accessreview/drivers/langfuse_test.go
Normal file
@@ -0,0 +1,74 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package drivers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.probo.inc/probo/pkg/coredata"
|
||||
)
|
||||
|
||||
func TestLangfuseDriver(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rec := newRecorder(t, "testdata/langfuse", "LANGFUSE_API_KEY")
|
||||
// Langfuse authenticates with HTTP Basic (publicKey:secretKey). The
|
||||
// matcher ignores Authorization, so replay needs no auth.
|
||||
client := newVCRClient(rec, basicAuthUserPass(os.Getenv("LANGFUSE_API_KEY")))
|
||||
|
||||
driver := NewLangfuseDriver(client, "https://cloud.langfuse.com")
|
||||
records, err := driver.ListAccounts(context.Background())
|
||||
require.NoError(t, err)
|
||||
require.Len(t, records, 4)
|
||||
|
||||
owner := records[0]
|
||||
assert.Equal(t, "lf-user-1", owner.ExternalID)
|
||||
assert.Equal(t, "owner@example.com", owner.Email)
|
||||
assert.Equal(t, "Olivia Owner", owner.FullName)
|
||||
assert.Equal(t, []string{"Owner"}, owner.Roles)
|
||||
assert.True(t, owner.IsAdmin)
|
||||
assert.Equal(t, coredata.AccessReviewEntryAccountTypeUser, owner.AccountType)
|
||||
|
||||
admin := records[1]
|
||||
assert.Equal(t, []string{"Admin"}, admin.Roles)
|
||||
assert.True(t, admin.IsAdmin)
|
||||
|
||||
member := records[2]
|
||||
assert.Equal(t, []string{"Member"}, member.Roles)
|
||||
assert.False(t, member.IsAdmin)
|
||||
|
||||
viewer := records[3]
|
||||
assert.Equal(t, []string{"Viewer"}, viewer.Roles)
|
||||
assert.False(t, viewer.IsAdmin)
|
||||
// name is empty in the payload, so the email is used as the display name.
|
||||
assert.Equal(t, "viewer@example.com", viewer.FullName)
|
||||
}
|
||||
|
||||
func TestLangfuseRoles(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
assert.Equal(t, []string{"Owner"}, langfuseRoles("OWNER"))
|
||||
assert.Equal(t, []string{"Admin"}, langfuseRoles("ADMIN"))
|
||||
assert.Equal(t, []string{"Member"}, langfuseRoles("MEMBER"))
|
||||
assert.Equal(t, []string{"Viewer"}, langfuseRoles("VIEWER"))
|
||||
assert.Equal(t, []string{"None"}, langfuseRoles("NONE"))
|
||||
assert.True(t, langfuseIsAdmin("OWNER"))
|
||||
assert.True(t, langfuseIsAdmin("ADMIN"))
|
||||
assert.False(t, langfuseIsAdmin("MEMBER"))
|
||||
}
|
||||
173
pkg/accessreview/drivers/mercury.go
Normal file
173
pkg/accessreview/drivers/mercury.go
Normal file
@@ -0,0 +1,173 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package drivers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"go.probo.inc/probo/pkg/coredata"
|
||||
)
|
||||
|
||||
const (
|
||||
mercuryUsersEndpoint = "https://api.mercury.com/api/v1/users"
|
||||
// mercuryUsersPageSize is the page size requested from GET /api/v1/users.
|
||||
// The API caps `limit` at 1000; 500 keeps responses small while still
|
||||
// returning every member of typical organizations in one page.
|
||||
mercuryUsersPageSize = 500
|
||||
)
|
||||
|
||||
// MercuryDriver lists the users of a single Mercury organization. The
|
||||
// access token (Bearer) is bound to one organization, so GET /api/v1/users
|
||||
// returns every member of that organization with no tenant selector.
|
||||
type MercuryDriver struct {
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
var _ Driver = (*MercuryDriver)(nil)
|
||||
|
||||
type mercuryUser struct {
|
||||
UserID string `json:"userId"`
|
||||
FirstName string `json:"firstName"`
|
||||
LastName string `json:"lastName"`
|
||||
Email string `json:"email"`
|
||||
OrganizationRole string `json:"organizationRole"`
|
||||
}
|
||||
|
||||
type mercuryUsersResponse struct {
|
||||
Users []mercuryUser `json:"users"`
|
||||
Page struct {
|
||||
NextPage *string `json:"nextPage"`
|
||||
} `json:"page"`
|
||||
}
|
||||
|
||||
func NewMercuryDriver(httpClient *http.Client) *MercuryDriver {
|
||||
return &MercuryDriver{httpClient: httpClient}
|
||||
}
|
||||
|
||||
func (d *MercuryDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error) {
|
||||
var (
|
||||
records []AccountRecord
|
||||
startAfter string
|
||||
)
|
||||
|
||||
for range maxPaginationPages {
|
||||
resp, err := d.fetchUsersPage(ctx, startAfter)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, u := range resp.Users {
|
||||
email := strings.TrimSpace(u.Email)
|
||||
if email == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
records = append(records, AccountRecord{
|
||||
Email: email,
|
||||
FullName: mercuryFullName(u, email),
|
||||
Roles: mercuryRoles(u.OrganizationRole),
|
||||
IsAdmin: u.OrganizationRole == "administrator",
|
||||
MFAStatus: coredata.MFAStatusUnknown,
|
||||
AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown,
|
||||
AccountType: coredata.AccessReviewEntryAccountTypeUser,
|
||||
ExternalID: strings.TrimSpace(u.UserID),
|
||||
})
|
||||
}
|
||||
|
||||
if resp.Page.NextPage == nil || *resp.Page.NextPage == "" {
|
||||
return records, nil
|
||||
}
|
||||
|
||||
startAfter = *resp.Page.NextPage
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("cannot list all mercury users: %w", ErrPaginationLimitReached)
|
||||
}
|
||||
|
||||
func (d *MercuryDriver) fetchUsersPage(ctx context.Context, startAfter string) (*mercuryUsersResponse, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, mercuryUsersEndpoint, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot create mercury users request: %w", err)
|
||||
}
|
||||
|
||||
q := req.URL.Query()
|
||||
q.Set("limit", strconv.Itoa(mercuryUsersPageSize))
|
||||
|
||||
if startAfter != "" {
|
||||
q.Set("start_after", startAfter)
|
||||
}
|
||||
|
||||
req.URL.RawQuery = q.Encode()
|
||||
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
httpResp, err := d.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot execute mercury users request: %w", err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
_ = httpResp.Body.Close()
|
||||
}()
|
||||
|
||||
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("cannot fetch mercury users: unexpected status %d", httpResp.StatusCode)
|
||||
}
|
||||
|
||||
var resp mercuryUsersResponse
|
||||
if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil {
|
||||
return nil, fmt.Errorf("cannot decode mercury users response: %w", err)
|
||||
}
|
||||
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
func mercuryFullName(u mercuryUser, fallback string) string {
|
||||
fullName := strings.TrimSpace(strings.TrimSpace(u.FirstName) + " " + strings.TrimSpace(u.LastName))
|
||||
if fullName != "" {
|
||||
return fullName
|
||||
}
|
||||
|
||||
return fallback
|
||||
}
|
||||
|
||||
// mercuryRoles maps Mercury's organizationRole enum
|
||||
// (administrator/bookkeeper/customUser/cardOnlyUser/employee) to a stable
|
||||
// display label, preserving unknown future roles verbatim.
|
||||
func mercuryRoles(role string) []string {
|
||||
switch role {
|
||||
case "administrator":
|
||||
return []string{"Administrator"}
|
||||
case "bookkeeper":
|
||||
return []string{"Bookkeeper"}
|
||||
case "customUser":
|
||||
return []string{"Custom User"}
|
||||
case "cardOnlyUser":
|
||||
return []string{"Card Only User"}
|
||||
case "employee":
|
||||
return []string{"Employee"}
|
||||
default:
|
||||
if role != "" {
|
||||
return []string{role}
|
||||
}
|
||||
|
||||
return []string{}
|
||||
}
|
||||
}
|
||||
78
pkg/accessreview/drivers/mercury_test.go
Normal file
78
pkg/accessreview/drivers/mercury_test.go
Normal file
@@ -0,0 +1,78 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package drivers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.probo.inc/probo/pkg/coredata"
|
||||
)
|
||||
|
||||
func TestMercuryDriver(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rec := newRecorder(t, "testdata/mercury", "MERCURY_API_TOKEN")
|
||||
client := newVCRClient(rec, bearerAuth(os.Getenv("MERCURY_API_TOKEN")))
|
||||
|
||||
driver := NewMercuryDriver(client)
|
||||
records, err := driver.ListAccounts(context.Background())
|
||||
require.NoError(t, err)
|
||||
require.Len(t, records, 3)
|
||||
|
||||
admin := records[0]
|
||||
assert.Equal(t, "8f1a6f1e-0000-4000-8000-000000000001", admin.ExternalID)
|
||||
assert.Equal(t, "ada@example.com", admin.Email)
|
||||
assert.Equal(t, "Ada Admin", admin.FullName)
|
||||
assert.Equal(t, []string{"Administrator"}, admin.Roles)
|
||||
assert.True(t, admin.IsAdmin)
|
||||
assert.Equal(t, coredata.AccessReviewEntryAccountTypeUser, admin.AccountType)
|
||||
assert.Equal(t, coredata.MFAStatusUnknown, admin.MFAStatus)
|
||||
|
||||
bookkeeper := records[1]
|
||||
assert.Equal(t, []string{"Bookkeeper"}, bookkeeper.Roles)
|
||||
assert.False(t, bookkeeper.IsAdmin)
|
||||
|
||||
employee := records[2]
|
||||
assert.Equal(t, []string{"Employee"}, employee.Roles)
|
||||
assert.False(t, employee.IsAdmin)
|
||||
}
|
||||
|
||||
func TestMercuryRoles(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
in string
|
||||
want []string
|
||||
}{
|
||||
{"administrator", []string{"Administrator"}},
|
||||
{"bookkeeper", []string{"Bookkeeper"}},
|
||||
{"customUser", []string{"Custom User"}},
|
||||
{"cardOnlyUser", []string{"Card Only User"}},
|
||||
{"employee", []string{"Employee"}},
|
||||
{"unknown_future_role", []string{"unknown_future_role"}},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.in, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
assert.Equal(t, c.want, mercuryRoles(c.in))
|
||||
})
|
||||
}
|
||||
}
|
||||
37
pkg/accessreview/drivers/testdata/apollo.yaml
vendored
Normal file
37
pkg/accessreview/drivers/testdata/apollo.yaml
vendored
Normal file
@@ -0,0 +1,37 @@
|
||||
---
|
||||
# Hand-authored fixture for GET /api/v1/users/search against an Apollo.io
|
||||
# account (teammates, not the prospect DB). The user object shape (id, name,
|
||||
# first_name, last_name, email, role) and the pagination wrapper mirror the
|
||||
# documented response. Synthetic IDs/emails only.
|
||||
version: 2
|
||||
interactions:
|
||||
- id: 0
|
||||
request:
|
||||
proto: HTTP/1.1
|
||||
proto_major: 1
|
||||
proto_minor: 1
|
||||
content_length: 0
|
||||
host: api.apollo.io
|
||||
form:
|
||||
page:
|
||||
- "1"
|
||||
per_page:
|
||||
- "100"
|
||||
headers:
|
||||
Accept:
|
||||
- application/json
|
||||
url: https://api.apollo.io/api/v1/users/search?page=1&per_page=100
|
||||
method: GET
|
||||
response:
|
||||
proto: HTTP/2.0
|
||||
proto_major: 2
|
||||
proto_minor: 0
|
||||
content_length: -1
|
||||
uncompressed: true
|
||||
body: '{"users":[{"id":"5f0000000000000000000001","name":"Alice Admin","first_name":"Alice","last_name":"Admin","email":"alice@example.com","role":"Admin"},{"id":"5f0000000000000000000002","name":"Bob Rep","first_name":"Bob","last_name":"Rep","email":"bob@example.com","role":"Sales Rep"},{"id":"5f0000000000000000000003","name":"Carol Manager","first_name":"Carol","last_name":"Manager","email":"carol@example.com","role":"Billing and Seat Manager"},{"id":"5f0000000000000000000004","name":"","first_name":"","last_name":"","email":"dave@example.com","role":"Sales Rep"}],"pagination":{"page":1,"per_page":100,"total_entries":4,"total_pages":1}}'
|
||||
headers:
|
||||
Content-Type:
|
||||
- application/json
|
||||
status: 200 OK
|
||||
code: 200
|
||||
duration: 140ms
|
||||
58
pkg/accessreview/drivers/testdata/clickhouse.yaml
vendored
Normal file
58
pkg/accessreview/drivers/testdata/clickhouse.yaml
vendored
Normal file
@@ -0,0 +1,58 @@
|
||||
---
|
||||
# Hand-authored fixture for the ClickHouse Cloud member-listing flow: GET
|
||||
# /v1/organizations (key maps to exactly one org) then GET
|
||||
# /v1/organizations/{id}/members. Responses use the {status,requestId,result}
|
||||
# envelope. assignedRoles is the live role source; the deprecated `role` field
|
||||
# is the fallback. Synthetic IDs/emails only.
|
||||
version: 2
|
||||
interactions:
|
||||
- id: 0
|
||||
request:
|
||||
proto: HTTP/1.1
|
||||
proto_major: 1
|
||||
proto_minor: 1
|
||||
content_length: 0
|
||||
host: api.clickhouse.cloud
|
||||
headers:
|
||||
Accept:
|
||||
- application/json
|
||||
url: https://api.clickhouse.cloud/v1/organizations
|
||||
method: GET
|
||||
response:
|
||||
proto: HTTP/2.0
|
||||
proto_major: 2
|
||||
proto_minor: 0
|
||||
content_length: -1
|
||||
uncompressed: true
|
||||
body: '{"status":200,"requestId":"11111111-1111-4111-8111-111111111111","result":[{"id":"aaaaaaaa-0000-4000-8000-000000000001","name":"Example Org","createdAt":"2025-12-01T08:00:00Z"}]}'
|
||||
headers:
|
||||
Content-Type:
|
||||
- application/json
|
||||
status: 200 OK
|
||||
code: 200
|
||||
duration: 110ms
|
||||
- id: 1
|
||||
request:
|
||||
proto: HTTP/1.1
|
||||
proto_major: 1
|
||||
proto_minor: 1
|
||||
content_length: 0
|
||||
host: api.clickhouse.cloud
|
||||
headers:
|
||||
Accept:
|
||||
- application/json
|
||||
url: https://api.clickhouse.cloud/v1/organizations/aaaaaaaa-0000-4000-8000-000000000001/members
|
||||
method: GET
|
||||
response:
|
||||
proto: HTTP/2.0
|
||||
proto_major: 2
|
||||
proto_minor: 0
|
||||
content_length: -1
|
||||
uncompressed: true
|
||||
body: '{"status":200,"requestId":"22222222-2222-4222-8222-222222222222","result":[{"userId":"u-0000-0001","name":"Admin User","email":"admin@example.com","role":"admin","joinedAt":"2026-01-02T10:00:00Z","assignedRoles":[{"roleId":"33333333-3333-4333-8333-333333333333","roleName":"Admin","roleType":"system"}]},{"userId":"u-0000-0002","name":"Dev User","email":"dev@example.com","role":"developer","joinedAt":"2026-02-03T11:00:00Z","assignedRoles":[]},{"userId":"u-0000-0003","name":"","email":"custom@example.com","role":"developer","joinedAt":"2026-03-04T12:00:00Z","assignedRoles":[{"roleId":"44444444-4444-4444-8444-444444444444","roleName":"Billing Admin","roleType":"custom"}]}]}'
|
||||
headers:
|
||||
Content-Type:
|
||||
- application/json
|
||||
status: 200 OK
|
||||
code: 200
|
||||
duration: 110ms
|
||||
82
pkg/accessreview/drivers/testdata/deepgram.yaml
vendored
Normal file
82
pkg/accessreview/drivers/testdata/deepgram.yaml
vendored
Normal file
@@ -0,0 +1,82 @@
|
||||
---
|
||||
# Hand-authored fixture for the Deepgram account-listing flow: GET /v1/projects
|
||||
# then GET /v1/projects/{project_id}/members for each project. owner@example.com
|
||||
# belongs to both projects (with different scopes), exercising the dedup +
|
||||
# scope-union path. Synthetic IDs/emails only.
|
||||
version: 2
|
||||
interactions:
|
||||
- id: 0
|
||||
request:
|
||||
proto: HTTP/1.1
|
||||
proto_major: 1
|
||||
proto_minor: 1
|
||||
content_length: 0
|
||||
host: api.deepgram.com
|
||||
headers:
|
||||
Accept:
|
||||
- application/json
|
||||
url: https://api.deepgram.com/v1/projects
|
||||
method: GET
|
||||
response:
|
||||
proto: HTTP/2.0
|
||||
proto_major: 2
|
||||
proto_minor: 0
|
||||
content_length: -1
|
||||
uncompressed: true
|
||||
body: '{"projects":[{"project_id":"proj-1111-1111","name":"Production"},{"project_id":"proj-2222-2222","name":"Staging"}]}'
|
||||
headers:
|
||||
Content-Type:
|
||||
- application/json
|
||||
status: 200 OK
|
||||
code: 200
|
||||
duration: 90ms
|
||||
- id: 1
|
||||
request:
|
||||
proto: HTTP/1.1
|
||||
proto_major: 1
|
||||
proto_minor: 1
|
||||
content_length: 0
|
||||
host: api.deepgram.com
|
||||
headers:
|
||||
Accept:
|
||||
- application/json
|
||||
url: https://api.deepgram.com/v1/projects/proj-1111-1111/members
|
||||
method: GET
|
||||
response:
|
||||
proto: HTTP/2.0
|
||||
proto_major: 2
|
||||
proto_minor: 0
|
||||
content_length: -1
|
||||
uncompressed: true
|
||||
body: '{"members":[{"member_id":"m-0000-0000-0001","email":"owner@example.com","first_name":"Olivia","last_name":"Owner","scopes":["member"]},{"member_id":"m-0000-0000-0002","email":"member@example.com","first_name":"Mia","last_name":"Member","scopes":["member"]}]}'
|
||||
headers:
|
||||
Content-Type:
|
||||
- application/json
|
||||
status: 200 OK
|
||||
code: 200
|
||||
duration: 90ms
|
||||
- id: 2
|
||||
request:
|
||||
proto: HTTP/1.1
|
||||
proto_major: 1
|
||||
proto_minor: 1
|
||||
content_length: 0
|
||||
host: api.deepgram.com
|
||||
headers:
|
||||
Accept:
|
||||
- application/json
|
||||
url: https://api.deepgram.com/v1/projects/proj-2222-2222/members
|
||||
method: GET
|
||||
response:
|
||||
proto: HTTP/2.0
|
||||
proto_major: 2
|
||||
proto_minor: 0
|
||||
content_length: -1
|
||||
uncompressed: true
|
||||
body: '{"members":[{"member_id":"m-0000-0000-0001","email":"owner@example.com","first_name":"Olivia","last_name":"Owner","scopes":["owner"]},{"member_id":"m-0000-0000-0003","email":"dev@example.com","first_name":"Dan","last_name":"Dev","scopes":["member"]}]}'
|
||||
headers:
|
||||
Content-Type:
|
||||
- application/json
|
||||
status: 200 OK
|
||||
code: 200
|
||||
duration: 90ms
|
||||
32
pkg/accessreview/drivers/testdata/langfuse.yaml
vendored
Normal file
32
pkg/accessreview/drivers/testdata/langfuse.yaml
vendored
Normal file
@@ -0,0 +1,32 @@
|
||||
---
|
||||
# Hand-authored fixture for GET /api/public/organizations/memberships against a
|
||||
# Langfuse organization (org-scoped API key). The membership object shape
|
||||
# (userId, role, email, name) and the {memberships:[...]} wrapper mirror the
|
||||
# documented response. Synthetic IDs/emails only.
|
||||
version: 2
|
||||
interactions:
|
||||
- id: 0
|
||||
request:
|
||||
proto: HTTP/1.1
|
||||
proto_major: 1
|
||||
proto_minor: 1
|
||||
content_length: 0
|
||||
host: cloud.langfuse.com
|
||||
headers:
|
||||
Accept:
|
||||
- application/json
|
||||
url: https://cloud.langfuse.com/api/public/organizations/memberships
|
||||
method: GET
|
||||
response:
|
||||
proto: HTTP/2.0
|
||||
proto_major: 2
|
||||
proto_minor: 0
|
||||
content_length: -1
|
||||
uncompressed: true
|
||||
body: '{"memberships":[{"userId":"lf-user-1","role":"OWNER","email":"owner@example.com","name":"Olivia Owner"},{"userId":"lf-user-2","role":"ADMIN","email":"admin@example.com","name":"Adam Admin"},{"userId":"lf-user-3","role":"MEMBER","email":"member@example.com","name":"Mia Member"},{"userId":"lf-user-4","role":"VIEWER","email":"viewer@example.com","name":""}]}'
|
||||
headers:
|
||||
Content-Type:
|
||||
- application/json
|
||||
status: 200 OK
|
||||
code: 200
|
||||
duration: 100ms
|
||||
35
pkg/accessreview/drivers/testdata/mercury.yaml
vendored
Normal file
35
pkg/accessreview/drivers/testdata/mercury.yaml
vendored
Normal file
@@ -0,0 +1,35 @@
|
||||
---
|
||||
# Hand-authored fixture for GET /api/v1/users against a Mercury organization.
|
||||
# The user object shape (userId, firstName, lastName, email, organizationRole)
|
||||
# mirrors the documented UserDetails schema; the page cursor is absent so the
|
||||
# driver stops after one page. Synthetic IDs/emails only.
|
||||
version: 2
|
||||
interactions:
|
||||
- id: 0
|
||||
request:
|
||||
proto: HTTP/1.1
|
||||
proto_major: 1
|
||||
proto_minor: 1
|
||||
content_length: 0
|
||||
host: api.mercury.com
|
||||
form:
|
||||
limit:
|
||||
- "500"
|
||||
headers:
|
||||
Accept:
|
||||
- application/json
|
||||
url: https://api.mercury.com/api/v1/users?limit=500
|
||||
method: GET
|
||||
response:
|
||||
proto: HTTP/2.0
|
||||
proto_major: 2
|
||||
proto_minor: 0
|
||||
content_length: -1
|
||||
uncompressed: true
|
||||
body: '{"users":[{"userId":"8f1a6f1e-0000-4000-8000-000000000001","firstName":"Ada","lastName":"Admin","email":"ada@example.com","organizationRole":"administrator"},{"userId":"8f1a6f1e-0000-4000-8000-000000000002","firstName":"Ben","lastName":"Books","email":"ben@example.com","organizationRole":"bookkeeper"},{"userId":"8f1a6f1e-0000-4000-8000-000000000003","firstName":"Eve","lastName":"Employee","email":"eve@example.com","organizationRole":"employee"}],"page":{"nextPage":null,"previousPage":null}}'
|
||||
headers:
|
||||
Content-Type:
|
||||
- application/json
|
||||
status: 200 OK
|
||||
code: 200
|
||||
duration: 120ms
|
||||
@@ -114,6 +114,20 @@ func basicAuth(username string) string {
|
||||
return "Basic " + base64.StdEncoding.EncodeToString([]byte(username+":"))
|
||||
}
|
||||
|
||||
// basicAuthUserPass returns the HTTP Basic auth header value for a credential
|
||||
// that already holds the "username:password" pair ("Basic
|
||||
// base64(<credential>)"), or "" if the credential is empty. ClickHouse
|
||||
// Cloud (keyId:keySecret) and Langfuse (publicKey:secretKey) present such
|
||||
// a credential. The matcher ignores Authorization, so this only matters
|
||||
// when re-recording.
|
||||
func basicAuthUserPass(credential string) string {
|
||||
if credential == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
return "Basic " + base64.StdEncoding.EncodeToString([]byte(credential))
|
||||
}
|
||||
|
||||
// newVCRClient creates an *http.Client backed by the recorder's transport,
|
||||
// with an optional Authorization header injected into requests (for recording
|
||||
// mode). The authValue should be the complete header value, e.g.
|
||||
|
||||
51
pkg/connector/provider/apollo.go
Normal file
51
pkg/connector/provider/apollo.go
Normal 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
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
52
pkg/connector/provider/clickhouse.go
Normal file
52
pkg/connector/provider/clickhouse.go
Normal 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
|
||||
},
|
||||
}
|
||||
}
|
||||
49
pkg/connector/provider/deepgram.go
Normal file
49
pkg/connector/provider/deepgram.go
Normal 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
|
||||
},
|
||||
}
|
||||
}
|
||||
89
pkg/connector/provider/langfuse.go
Normal file
89
pkg/connector/provider/langfuse.go
Normal 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
|
||||
}
|
||||
108
pkg/connector/provider/langfuse_test.go
Normal file
108
pkg/connector/provider/langfuse_test.go
Normal file
@@ -0,0 +1,108 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package 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")
|
||||
})
|
||||
}
|
||||
51
pkg/connector/provider/mercury.go
Normal file
51
pkg/connector/provider/mercury.go
Normal 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
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
15
pkg/coredata/migrations/20260612T263841Z.sql
Normal file
15
pkg/coredata/migrations/20260612T263841Z.sql
Normal 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';
|
||||
15
pkg/coredata/migrations/20260612T318094Z.sql
Normal file
15
pkg/coredata/migrations/20260612T318094Z.sql
Normal 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';
|
||||
15
pkg/coredata/migrations/20260612T489176Z.sql
Normal file
15
pkg/coredata/migrations/20260612T489176Z.sql
Normal 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';
|
||||
15
pkg/coredata/migrations/20260612T642517Z.sql
Normal file
15
pkg/coredata/migrations/20260612T642517Z.sql
Normal 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';
|
||||
15
pkg/coredata/migrations/20260612T705263Z.sql
Normal file
15
pkg/coredata/migrations/20260612T705263Z.sql
Normal 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';
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user