Add PostHog Cloud OAuth and self-hosted support
PostHog Cloud authenticates via CIMD OAuth (public client, PKCE) through the region-agnostic oauth.posthog.com gateway, with an API-key fallback. PostHog Self-Hosted is a separate provider using an API key and an instance URL. The shared driver discovers the data region by probing us/eu for OAuth connections, since the gateway does not serve the data API, and pins pagination to the resolved host. Signed-off-by: Aurélien Sibiril <81782+aureliensibiril@users.noreply.github.com>
This commit is contained in:
@@ -122,6 +122,10 @@
|
|||||||
# CONNECTOR_VERCEL_INTEGRATION_SLUG=
|
# CONNECTOR_VERCEL_INTEGRATION_SLUG=
|
||||||
# CONNECTOR_MONDAY_CLIENT_ID=
|
# CONNECTOR_MONDAY_CLIENT_ID=
|
||||||
# CONNECTOR_MONDAY_CLIENT_SECRET=
|
# CONNECTOR_MONDAY_CLIENT_SECRET=
|
||||||
|
# PostHog Cloud (US + EU) OAuth needs NO config: it uses the CIMD public-client
|
||||||
|
# flow (no app registration, no client_secret), auto-enabled when this
|
||||||
|
# deployment is publicly reachable at PROBOD_BASE_URL. Self-hosted PostHog uses
|
||||||
|
# the API-key path with an instance URL. No CONNECTOR_POSTHOG_* vars required.
|
||||||
|
|
||||||
# ── Custom domains (Pebble ACME via compose) ──────────────────────────
|
# ── Custom domains (Pebble ACME via compose) ──────────────────────────
|
||||||
# CUSTOM_DOMAINS_CNAME_TARGET=custom.getprobo.com
|
# CUSTOM_DOMAINS_CNAME_TARGET=custom.getprobo.com
|
||||||
|
|||||||
@@ -29,14 +29,24 @@ import (
|
|||||||
|
|
||||||
type PostHogDriver struct {
|
type PostHogDriver struct {
|
||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
|
baseURL string
|
||||||
}
|
}
|
||||||
|
|
||||||
var _ Driver = (*PostHogDriver)(nil)
|
var _ Driver = (*PostHogDriver)(nil)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
posthogMembersEndpoint = "https://app.posthog.com/api/organizations/@current/members/"
|
posthogMembersPath = "/api/organizations/@current/members/"
|
||||||
posthogOrganizationEndpoint = "https://app.posthog.com/api/organizations/@current/"
|
posthogOrganizationPath = "/api/organizations/@current/"
|
||||||
posthogMembersPageSize = 100
|
posthogMembersPageSize = 100
|
||||||
|
|
||||||
|
// PostHog Cloud regional data hosts. OAuth connections carry no region
|
||||||
|
// (empty baseURL): the region-agnostic oauth.posthog.com gateway used
|
||||||
|
// for the OAuth handshake does NOT serve the data API, so the driver
|
||||||
|
// discovers the region by probing these hosts with the connection's
|
||||||
|
// token. API-key (us/eu) and self-hosted connections always carry an
|
||||||
|
// explicit host instead.
|
||||||
|
posthogUSBaseURL = "https://us.posthog.com"
|
||||||
|
posthogEUBaseURL = "https://eu.posthog.com"
|
||||||
|
|
||||||
posthogMembershipLevelMember = 1
|
posthogMembershipLevelMember = 1
|
||||||
posthogMembershipLevelAdmin = 8
|
posthogMembershipLevelAdmin = 8
|
||||||
@@ -67,12 +77,39 @@ type (
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
func NewPostHogDriver(httpClient *http.Client) *PostHogDriver {
|
// NewPostHogDriver builds a driver against baseURL (e.g. https://us.posthog.com
|
||||||
return &PostHogDriver{httpClient: httpClient}
|
// or a self-hosted instance URL). An empty baseURL marks a cloud OAuth
|
||||||
|
// connection whose region is discovered lazily on first use (see resolveBaseURL).
|
||||||
|
func NewPostHogDriver(httpClient *http.Client, baseURL string) *PostHogDriver {
|
||||||
|
return &PostHogDriver{httpClient: httpClient, baseURL: baseURL}
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveBaseURL ensures the driver has a concrete data host. Explicit hosts
|
||||||
|
// (API-key region / self-hosted) are used as-is; an empty baseURL (cloud
|
||||||
|
// OAuth) is resolved by probing the PostHog Cloud regions with the
|
||||||
|
// connection's token, since the oauth.posthog.com gateway does not serve /api.
|
||||||
|
// The result is cached on the driver for subsequent pages.
|
||||||
|
func (d *PostHogDriver) resolveBaseURL(ctx context.Context) error {
|
||||||
|
if d.baseURL != "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
host, err := resolvePostHogRegion(ctx, d.httpClient)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
d.baseURL = host
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *PostHogDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error) {
|
func (d *PostHogDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error) {
|
||||||
nextURL, err := buildPostHogMembersURL()
|
if err := d.resolveBaseURL(ctx); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
nextURL, err := d.membersURL()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -98,7 +135,7 @@ func (d *PostHogDriver) ListAccounts(ctx context.Context) ([]AccountRecord, erro
|
|||||||
return records, nil
|
return records, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
nextURL, err = resolvePostHogNextURL(resp.Next)
|
nextURL, err = d.resolveNextURL(resp.Next)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -107,8 +144,13 @@ func (d *PostHogDriver) ListAccounts(ctx context.Context) ([]AccountRecord, erro
|
|||||||
return nil, fmt.Errorf("cannot list all posthog accounts: %w", ErrPaginationLimitReached)
|
return nil, fmt.Errorf("cannot list all posthog accounts: %w", ErrPaginationLimitReached)
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildPostHogMembersURL() (string, error) {
|
func (d *PostHogDriver) membersURL() (string, error) {
|
||||||
u, err := url.Parse(posthogMembersEndpoint)
|
endpoint, err := url.JoinPath(d.baseURL, posthogMembersPath)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("cannot build posthog members URL: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
u, err := url.Parse(endpoint)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("cannot parse posthog members URL: %w", err)
|
return "", fmt.Errorf("cannot parse posthog members URL: %w", err)
|
||||||
}
|
}
|
||||||
@@ -121,17 +163,36 @@ func buildPostHogMembersURL() (string, error) {
|
|||||||
return u.String(), nil
|
return u.String(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func resolvePostHogNextURL(next string) (string, error) {
|
func (d *PostHogDriver) resolveNextURL(next string) (string, error) {
|
||||||
nextURL, err := url.Parse(next)
|
nextURL, err := url.Parse(next)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("cannot parse posthog next page URL: %w", err)
|
return "", fmt.Errorf("cannot parse posthog next page URL: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
base, err := url.Parse(d.baseURL)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("cannot parse posthog base URL: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
if nextURL.IsAbs() {
|
if nextURL.IsAbs() {
|
||||||
|
// Pin pagination to the resolved data host. The connection's bearer
|
||||||
|
// token is attached to every request, so an absolute `next` pointing
|
||||||
|
// at a different host (a compromised or spoofed API response) would
|
||||||
|
// forward the token off-host. Refuse cross-host pagination; the
|
||||||
|
// error is static so it never echoes an attacker-controlled host.
|
||||||
|
if !strings.EqualFold(nextURL.Host, base.Host) {
|
||||||
|
return "", fmt.Errorf("cannot follow posthog next page URL: cross-host pagination is not allowed")
|
||||||
|
}
|
||||||
|
|
||||||
return nextURL.String(), nil
|
return nextURL.String(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
baseURL, err := url.Parse(posthogMembersEndpoint)
|
endpoint, err := url.JoinPath(d.baseURL, posthogMembersPath)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("cannot build posthog members base URL: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
baseURL, err := url.Parse(endpoint)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("cannot parse posthog members base URL: %w", err)
|
return "", fmt.Errorf("cannot parse posthog members base URL: %w", err)
|
||||||
}
|
}
|
||||||
@@ -139,6 +200,47 @@ func resolvePostHogNextURL(next string) (string, error) {
|
|||||||
return baseURL.ResolveReference(nextURL).String(), nil
|
return baseURL.ResolveReference(nextURL).String(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// resolvePostHogRegion probes the PostHog Cloud region hosts with the given
|
||||||
|
// token-bearing client and returns the first that answers 2xx on the @current
|
||||||
|
// organization endpoint. OAuth connections authenticate via the region-agnostic
|
||||||
|
// oauth.posthog.com gateway, which does not serve /api, so the actual data
|
||||||
|
// region (us/eu) must be discovered against the regional hosts directly.
|
||||||
|
func resolvePostHogRegion(ctx context.Context, client *http.Client) (string, error) {
|
||||||
|
for _, host := range []string{posthogUSBaseURL, posthogEUBaseURL} {
|
||||||
|
endpoint, err := url.JoinPath(host, posthogOrganizationPath)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
// Surface a cancelled/expired context as the real cause rather
|
||||||
|
// than masking it behind "no region accepted the connection".
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return "", fmt.Errorf("cannot resolve posthog region: %w", ctx.Err())
|
||||||
|
}
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
status := resp.StatusCode
|
||||||
|
_ = resp.Body.Close()
|
||||||
|
|
||||||
|
if status >= http.StatusOK && status < http.StatusMultipleChoices {
|
||||||
|
return host, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", fmt.Errorf("cannot resolve posthog region: no region accepted the connection")
|
||||||
|
}
|
||||||
|
|
||||||
func (d *PostHogDriver) fetchMembers(
|
func (d *PostHogDriver) fetchMembers(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
nextURL string,
|
nextURL string,
|
||||||
@@ -159,7 +261,7 @@ func (d *PostHogDriver) fetchMembers(
|
|||||||
_ = httpResp.Body.Close()
|
_ = httpResp.Body.Close()
|
||||||
}()
|
}()
|
||||||
|
|
||||||
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
|
if httpResp.StatusCode < http.StatusOK || httpResp.StatusCode >= http.StatusMultipleChoices {
|
||||||
return nil, fmt.Errorf("cannot fetch posthog members: unexpected status %d", httpResp.StatusCode)
|
return nil, fmt.Errorf("cannot fetch posthog members: unexpected status %d", httpResp.StatusCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -234,22 +336,44 @@ func posthogMFAStatus(twoFAEnabled *bool) coredata.MFAStatus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// posthogNameResolver resolves the PostHog organization name from the
|
// posthogNameResolver resolves the PostHog organization name from the
|
||||||
// current organization endpoint, which returns the org an API key belongs to.
|
// current organization endpoint, which returns the org the connection
|
||||||
|
// belongs to.
|
||||||
type posthogNameResolver struct {
|
type posthogNameResolver struct {
|
||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
|
baseURL string
|
||||||
}
|
}
|
||||||
|
|
||||||
var _ NameResolver = (*posthogNameResolver)(nil)
|
var _ NameResolver = (*posthogNameResolver)(nil)
|
||||||
|
|
||||||
func NewPostHogNameResolver(httpClient *http.Client) NameResolver {
|
// NewPostHogNameResolver resolves the org name against baseURL. An empty
|
||||||
return &posthogNameResolver{httpClient: httpClient}
|
// baseURL marks a cloud OAuth connection whose region is discovered lazily.
|
||||||
|
func NewPostHogNameResolver(httpClient *http.Client, baseURL string) NameResolver {
|
||||||
|
return &posthogNameResolver{httpClient: httpClient, baseURL: baseURL}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *posthogNameResolver) ResolveInstanceName(ctx context.Context) (string, error) {
|
func (r *posthogNameResolver) ResolveInstanceName(ctx context.Context) (string, error) {
|
||||||
|
baseURL := r.baseURL
|
||||||
|
if baseURL == "" {
|
||||||
|
host, err := resolvePostHogRegion(ctx, r.httpClient)
|
||||||
|
if err != nil {
|
||||||
|
// Terminal: cannot determine the region (e.g. revoked token).
|
||||||
|
// Keep the generic source name rather than making the
|
||||||
|
// source-name worker retry forever.
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
baseURL = host
|
||||||
|
}
|
||||||
|
|
||||||
|
endpoint, err := url.JoinPath(baseURL, posthogOrganizationPath)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("cannot build posthog organization URL: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(
|
req, err := http.NewRequestWithContext(
|
||||||
ctx,
|
ctx,
|
||||||
http.MethodGet,
|
http.MethodGet,
|
||||||
posthogOrganizationEndpoint,
|
endpoint,
|
||||||
nil,
|
nil,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -268,7 +392,7 @@ func (r *posthogNameResolver) ResolveInstanceName(ctx context.Context) (string,
|
|||||||
// Best-effort: a non-2xx (e.g. a revoked key) must not make the
|
// Best-effort: a non-2xx (e.g. a revoked key) must not make the
|
||||||
// source-name worker retry forever. Give up gracefully and keep the
|
// source-name worker retry forever. Give up gracefully and keep the
|
||||||
// generic source name; a dead key surfaces on the next ListAccounts.
|
// generic source name; a dead key surfaces on the next ListAccounts.
|
||||||
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
|
if httpResp.StatusCode < http.StatusOK || httpResp.StatusCode >= http.StatusMultipleChoices {
|
||||||
return "", nil
|
return "", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,9 +16,11 @@ package drivers
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
@@ -32,7 +34,7 @@ func TestPostHogDriverListAccounts(t *testing.T) {
|
|||||||
rec := newRecorder(t, "testdata/posthog", "POSTHOG_PERSONAL_API_KEY")
|
rec := newRecorder(t, "testdata/posthog", "POSTHOG_PERSONAL_API_KEY")
|
||||||
client := newVCRClient(rec, bearerAuth(os.Getenv("POSTHOG_PERSONAL_API_KEY")))
|
client := newVCRClient(rec, bearerAuth(os.Getenv("POSTHOG_PERSONAL_API_KEY")))
|
||||||
|
|
||||||
records, err := NewPostHogDriver(client).ListAccounts(context.Background())
|
records, err := NewPostHogDriver(client, "https://app.posthog.com").ListAccounts(context.Background())
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Len(t, records, 3)
|
require.Len(t, records, 3)
|
||||||
|
|
||||||
@@ -63,6 +65,61 @@ func TestPostHogDriverListAccounts(t *testing.T) {
|
|||||||
require.NotNil(t, admin.CreatedAt)
|
require.NotNil(t, admin.CreatedAt)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestPostHogDriverResolvesRegionLazily covers the OAuth path: an empty
|
||||||
|
// baseURL means the region-agnostic gateway was used for the handshake, so
|
||||||
|
// the driver must discover the data region (us/eu) by probing before listing.
|
||||||
|
func TestPostHogDriverResolvesRegionLazily(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
resp := func(status int, body string) *http.Response {
|
||||||
|
return &http.Response{
|
||||||
|
StatusCode: status,
|
||||||
|
Body: io.NopCloser(strings.NewReader(body)),
|
||||||
|
Header: make(http.Header),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const euMembers = `{"count":1,"next":"","results":[{"id":"m1","user":{"uuid":"u1","first_name":"A","last_name":"B","email":"a@b.com"},"level":1,"joined_at":"2025-01-01T00:00:00Z"}]}`
|
||||||
|
|
||||||
|
t.Run("empty base URL probes US then falls back to EU", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var usHits, euHits int
|
||||||
|
|
||||||
|
client := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||||
|
switch req.URL.Host {
|
||||||
|
case "us.posthog.com":
|
||||||
|
usHits++
|
||||||
|
return resp(http.StatusUnauthorized, `{"detail":"unauthorized"}`), nil
|
||||||
|
case "eu.posthog.com":
|
||||||
|
euHits++
|
||||||
|
return resp(http.StatusOK, euMembers), nil
|
||||||
|
default:
|
||||||
|
return resp(http.StatusNotFound, ""), nil
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
|
||||||
|
records, err := NewPostHogDriver(client, "").ListAccounts(context.Background())
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, records, 1)
|
||||||
|
assert.Equal(t, "a@b.com", records[0].Email)
|
||||||
|
assert.Positive(t, usHits, "US region must be probed")
|
||||||
|
assert.Positive(t, euHits, "EU region must be used after US refuses")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("no region accepts the token returns an error", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
client := &http.Client{Transport: roundTripFunc(func(_ *http.Request) (*http.Response, error) {
|
||||||
|
return resp(http.StatusForbidden, `{"detail":"forbidden"}`), nil
|
||||||
|
})}
|
||||||
|
|
||||||
|
_, err := NewPostHogDriver(client, "").ListAccounts(context.Background())
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "cannot resolve posthog region")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func TestPostHogNameResolver(t *testing.T) {
|
func TestPostHogNameResolver(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -108,7 +165,7 @@ func TestPostHogNameResolver(t *testing.T) {
|
|||||||
|
|
||||||
client := &http.Client{Transport: &hostRewriter{target: srv.URL}}
|
client := &http.Client{Transport: &hostRewriter{target: srv.URL}}
|
||||||
|
|
||||||
got, err := NewPostHogNameResolver(client).ResolveInstanceName(context.Background())
|
got, err := NewPostHogNameResolver(client, "https://app.posthog.com").ResolveInstanceName(context.Background())
|
||||||
if tc.wantErr {
|
if tc.wantErr {
|
||||||
require.Error(t, err)
|
require.Error(t, err)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ func TestApplyOAuth2Defaults_AuthURLFromSlug(t *testing.T) {
|
|||||||
func TestApplyOAuth2Defaults_PKCEDefaults(t *testing.T) {
|
func TestApplyOAuth2Defaults_PKCEDefaults(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
for _, p := range []string{"PAGERDUTY"} {
|
for _, p := range []string{"PAGERDUTY", "POSTHOG"} {
|
||||||
t.Run(p, func(t *testing.T) {
|
t.Run(p, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -98,3 +98,18 @@ func TestApplyOAuth2Defaults_PKCEDefaults(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestApplyOAuth2Defaults_PublicClientTokenAuth verifies that PostHog, a
|
||||||
|
// public (CIMD) client, propagates token_endpoint_auth_method "none" so the
|
||||||
|
// token exchange omits a client_secret.
|
||||||
|
func TestApplyOAuth2Defaults_PublicClientTokenAuth(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
r := provider.NewBuiltinRegistry()
|
||||||
|
c := &connector.OAuth2Connector{}
|
||||||
|
require.NoError(t, r.ApplyOAuth2Defaults("POSTHOG", "https://example.com/cb", c))
|
||||||
|
|
||||||
|
assert.Equal(t, "none", c.TokenEndpointAuth,
|
||||||
|
"PostHog must use token_endpoint_auth_method none (public client)")
|
||||||
|
assert.True(t, c.RequiresPKCE, "PostHog public client must require PKCE")
|
||||||
|
}
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ func NewBuiltinRegistry() *Registry {
|
|||||||
onePasswordRegistration(),
|
onePasswordRegistration(),
|
||||||
openaiRegistration(),
|
openaiRegistration(),
|
||||||
posthogRegistration(),
|
posthogRegistration(),
|
||||||
|
posthogSelfHostedRegistration(),
|
||||||
pagerdutyRegistration(),
|
pagerdutyRegistration(),
|
||||||
resendRegistration(),
|
resendRegistration(),
|
||||||
sentryRegistration(),
|
sentryRegistration(),
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ package provider
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"go.gearno.de/kit/log"
|
"go.gearno.de/kit/log"
|
||||||
@@ -23,16 +24,61 @@ import (
|
|||||||
"go.probo.inc/probo/pkg/coredata"
|
"go.probo.inc/probo/pkg/coredata"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// posthogRegistration is PostHog Cloud (US + EU). OAuth is the preferred
|
||||||
|
// path: oauth.posthog.com is PostHog's region-agnostic OAuth + API gateway,
|
||||||
|
// so one app serves both regions and the driver reaches the customer's data
|
||||||
|
// through it without a per-connection host. An API-key fallback is also
|
||||||
|
// supported, but personal API keys are region-pinned, so it requires the
|
||||||
|
// customer to pick their region (us/eu). Self-hosted instances are a separate
|
||||||
|
// provider (POSTHOG_SELF_HOSTED).
|
||||||
func posthogRegistration() *Registration {
|
func posthogRegistration() *Registration {
|
||||||
return &Registration{
|
return &Registration{
|
||||||
Provider: coredata.ConnectorProviderPostHog,
|
Provider: coredata.ConnectorProviderPostHog,
|
||||||
DisplayName: "PostHog",
|
DisplayName: "PostHog",
|
||||||
|
|
||||||
|
// PublicClient: PostHog OAuth uses the CIMD flow — no client_secret,
|
||||||
|
// authenticated by PKCE. probod auto-registers this connector with
|
||||||
|
// the deployment's hosted CIMD client_id; no operator OAuth app or
|
||||||
|
// credentials are required.
|
||||||
|
PublicClient: true,
|
||||||
|
AuthURL: "https://oauth.posthog.com/oauth/authorize/",
|
||||||
|
TokenURL: "https://oauth.posthog.com/oauth/token/",
|
||||||
|
TokenEndpointAuth: "none",
|
||||||
|
RequiresPKCE: true,
|
||||||
|
OAuth2Scopes: []string{"organization:read", "organization_member:read"},
|
||||||
|
// required_access_level=organization makes consent org-scoped so
|
||||||
|
// organization_member:read applies org-wide and the org endpoints
|
||||||
|
// resolve @current to the granted organization.
|
||||||
|
ExtraAuthParams: map[string]string{"required_access_level": "organization"},
|
||||||
|
// ProbeURL is intentionally empty: the data host varies per
|
||||||
|
// connection (the region-agnostic gateway for OAuth, us/eu for
|
||||||
|
// API-key), so a single static probe URL cannot match it. A dead
|
||||||
|
// token surfaces on the first ListAccounts.
|
||||||
|
|
||||||
SupportsAPIKey: true,
|
SupportsAPIKey: true,
|
||||||
NewDriver: func(_ context.Context, c *http.Client, _ *coredata.Connector, _ *log.Logger) (drivers.Driver, error) {
|
ExtraSettings: []ExtraSetting{
|
||||||
return drivers.NewPostHogDriver(c), nil
|
{Key: "region", Label: "Region", Required: true},
|
||||||
},
|
},
|
||||||
NewNameResolver: func(_ context.Context, c *http.Client, _ *coredata.Connector, _ *log.Logger) drivers.NameResolver {
|
|
||||||
return drivers.NewPostHogNameResolver(c)
|
NewDriver: func(_ context.Context, c *http.Client, conn *coredata.Connector, _ *log.Logger) (drivers.Driver, error) {
|
||||||
|
s, err := coredata.ConnectorSettings[coredata.PostHogConnectorSettings](conn)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("cannot read posthog connector settings: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// BaseURL is empty for cloud OAuth connections; the driver then
|
||||||
|
// discovers the region (us/eu) lazily by probing, since the
|
||||||
|
// oauth.posthog.com gateway does not serve the data API.
|
||||||
|
return drivers.NewPostHogDriver(c, s.BaseURL), nil
|
||||||
|
},
|
||||||
|
NewNameResolver: func(ctx context.Context, c *http.Client, conn *coredata.Connector, logger *log.Logger) drivers.NameResolver {
|
||||||
|
s, err := coredata.ConnectorSettings[coredata.PostHogConnectorSettings](conn)
|
||||||
|
if err != nil {
|
||||||
|
logger.ErrorCtx(ctx, "cannot read posthog connector settings", log.Error(err))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return drivers.NewPostHogNameResolver(c, s.BaseURL)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
71
pkg/connector/provider/posthog_self_hosted.go
Normal file
71
pkg/connector/provider/posthog_self_hosted.go
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
// Copyright (c) 2026 Probo Inc <hello@getprobo.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"
|
||||||
|
|
||||||
|
"go.gearno.de/kit/log"
|
||||||
|
"go.probo.inc/probo/pkg/accessreview/drivers"
|
||||||
|
"go.probo.inc/probo/pkg/coredata"
|
||||||
|
)
|
||||||
|
|
||||||
|
// posthogSelfHostedRegistration is customer-hosted PostHog: API-key plus an
|
||||||
|
// operator-supplied instance URL (Metabase/Grafana style). It shares the
|
||||||
|
// PostHog driver and name resolver, pointed at the instance's BaseURL. OAuth
|
||||||
|
// is deliberately not offered here — a single static authorization URL cannot
|
||||||
|
// serve arbitrary per-customer instances, so self-hosted OAuth is a separate
|
||||||
|
// future effort. Cloud PostHog (POSTHOG) owns the OAuth path.
|
||||||
|
func posthogSelfHostedRegistration() *Registration {
|
||||||
|
return &Registration{
|
||||||
|
Provider: coredata.ConnectorProviderPostHogSelfHosted,
|
||||||
|
DisplayName: "PostHog (Self-Hosted)",
|
||||||
|
|
||||||
|
SupportsAPIKey: true,
|
||||||
|
ExtraSettings: []ExtraSetting{
|
||||||
|
{Key: "instanceUrl", Label: "Instance URL", Required: true},
|
||||||
|
},
|
||||||
|
|
||||||
|
NewDriver: func(_ context.Context, c *http.Client, conn *coredata.Connector, _ *log.Logger) (drivers.Driver, error) {
|
||||||
|
s, err := coredata.ConnectorSettings[coredata.PostHogConnectorSettings](conn)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("cannot read posthog self-hosted connector settings: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Never fall back to the cloud gateway for a self-hosted
|
||||||
|
// connector — the instance URL is required at creation time.
|
||||||
|
if s.BaseURL == "" {
|
||||||
|
return nil, fmt.Errorf("cannot create posthog self-hosted driver: instance URL is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
return drivers.NewPostHogDriver(c, s.BaseURL), nil
|
||||||
|
},
|
||||||
|
NewNameResolver: func(ctx context.Context, c *http.Client, conn *coredata.Connector, logger *log.Logger) drivers.NameResolver {
|
||||||
|
s, err := coredata.ConnectorSettings[coredata.PostHogConnectorSettings](conn)
|
||||||
|
if err != nil {
|
||||||
|
logger.ErrorCtx(ctx, "cannot read posthog self-hosted connector settings", log.Error(err))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.BaseURL == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return drivers.NewPostHogNameResolver(c, s.BaseURL)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,35 +26,41 @@ const (
|
|||||||
ConnectorProviderGoogleWorkspace ConnectorProvider = "GOOGLE_WORKSPACE"
|
ConnectorProviderGoogleWorkspace ConnectorProvider = "GOOGLE_WORKSPACE"
|
||||||
ConnectorProviderLinear ConnectorProvider = "LINEAR"
|
ConnectorProviderLinear ConnectorProvider = "LINEAR"
|
||||||
// _ ConnectorProvider = "FIGMA" — formerly Figma; removed (no driver, no OAuth config, no usage)
|
// _ ConnectorProvider = "FIGMA" — formerly Figma; removed (no driver, no OAuth config, no usage)
|
||||||
ConnectorProviderOnePassword ConnectorProvider = "ONE_PASSWORD"
|
ConnectorProviderOnePassword ConnectorProvider = "ONE_PASSWORD"
|
||||||
ConnectorProviderHubSpot ConnectorProvider = "HUBSPOT"
|
ConnectorProviderHubSpot ConnectorProvider = "HUBSPOT"
|
||||||
ConnectorProviderDocuSign ConnectorProvider = "DOCUSIGN"
|
ConnectorProviderDocuSign ConnectorProvider = "DOCUSIGN"
|
||||||
ConnectorProviderNotion ConnectorProvider = "NOTION"
|
ConnectorProviderNotion ConnectorProvider = "NOTION"
|
||||||
ConnectorProviderBrex ConnectorProvider = "BREX"
|
ConnectorProviderBrex ConnectorProvider = "BREX"
|
||||||
ConnectorProviderTally ConnectorProvider = "TALLY"
|
ConnectorProviderTally ConnectorProvider = "TALLY"
|
||||||
ConnectorProviderCloudflare ConnectorProvider = "CLOUDFLARE"
|
ConnectorProviderCloudflare ConnectorProvider = "CLOUDFLARE"
|
||||||
ConnectorProviderGrafana ConnectorProvider = "GRAFANA"
|
ConnectorProviderGrafana ConnectorProvider = "GRAFANA"
|
||||||
ConnectorProviderOpenAI ConnectorProvider = "OPENAI"
|
ConnectorProviderOpenAI ConnectorProvider = "OPENAI"
|
||||||
ConnectorProviderPostHog ConnectorProvider = "POSTHOG"
|
ConnectorProviderPostHog ConnectorProvider = "POSTHOG"
|
||||||
ConnectorProviderSentry ConnectorProvider = "SENTRY"
|
// ConnectorProviderPostHogSelfHosted is a distinct provider for
|
||||||
ConnectorProviderSupabase ConnectorProvider = "SUPABASE"
|
// customer-hosted PostHog instances: API-key + operator-supplied
|
||||||
ConnectorProviderGitHub ConnectorProvider = "GITHUB"
|
// instance URL. Cloud PostHog (POSTHOG) carries the OAuth path; a
|
||||||
ConnectorProviderIntercom ConnectorProvider = "INTERCOM"
|
// separate provider keeps the per-connection instance URL out of the
|
||||||
ConnectorProviderResend ConnectorProvider = "RESEND"
|
// shared OAuth flow.
|
||||||
ConnectorProviderMicrosoft365 ConnectorProvider = "MICROSOFT_365"
|
ConnectorProviderPostHogSelfHosted ConnectorProvider = "POSTHOG_SELF_HOSTED"
|
||||||
ConnectorProviderGitLab ConnectorProvider = "GITLAB"
|
ConnectorProviderSentry ConnectorProvider = "SENTRY"
|
||||||
ConnectorProviderBitbucket ConnectorProvider = "BITBUCKET"
|
ConnectorProviderSupabase ConnectorProvider = "SUPABASE"
|
||||||
ConnectorProviderHeroku ConnectorProvider = "HEROKU"
|
ConnectorProviderGitHub ConnectorProvider = "GITHUB"
|
||||||
ConnectorProviderPagerDuty ConnectorProvider = "PAGERDUTY"
|
ConnectorProviderIntercom ConnectorProvider = "INTERCOM"
|
||||||
ConnectorProviderAsana ConnectorProvider = "ASANA"
|
ConnectorProviderResend ConnectorProvider = "RESEND"
|
||||||
ConnectorProviderNetlify ConnectorProvider = "NETLIFY"
|
ConnectorProviderMicrosoft365 ConnectorProvider = "MICROSOFT_365"
|
||||||
ConnectorProviderClickUp ConnectorProvider = "CLICKUP"
|
ConnectorProviderGitLab ConnectorProvider = "GITLAB"
|
||||||
ConnectorProviderVercel ConnectorProvider = "VERCEL"
|
ConnectorProviderBitbucket ConnectorProvider = "BITBUCKET"
|
||||||
ConnectorProviderMonday ConnectorProvider = "MONDAY"
|
ConnectorProviderHeroku ConnectorProvider = "HEROKU"
|
||||||
ConnectorProviderMetabase ConnectorProvider = "METABASE"
|
ConnectorProviderPagerDuty ConnectorProvider = "PAGERDUTY"
|
||||||
ConnectorProviderTailscale ConnectorProvider = "TAILSCALE"
|
ConnectorProviderAsana ConnectorProvider = "ASANA"
|
||||||
ConnectorProviderAnthropic ConnectorProvider = "ANTHROPIC"
|
ConnectorProviderNetlify ConnectorProvider = "NETLIFY"
|
||||||
ConnectorProviderCursor ConnectorProvider = "CURSOR"
|
ConnectorProviderClickUp ConnectorProvider = "CLICKUP"
|
||||||
|
ConnectorProviderVercel ConnectorProvider = "VERCEL"
|
||||||
|
ConnectorProviderMonday ConnectorProvider = "MONDAY"
|
||||||
|
ConnectorProviderMetabase ConnectorProvider = "METABASE"
|
||||||
|
ConnectorProviderTailscale ConnectorProvider = "TAILSCALE"
|
||||||
|
ConnectorProviderAnthropic ConnectorProvider = "ANTHROPIC"
|
||||||
|
ConnectorProviderCursor ConnectorProvider = "CURSOR"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -78,6 +84,7 @@ func ConnectorProviders() []ConnectorProvider {
|
|||||||
ConnectorProviderGrafana,
|
ConnectorProviderGrafana,
|
||||||
ConnectorProviderOpenAI,
|
ConnectorProviderOpenAI,
|
||||||
ConnectorProviderPostHog,
|
ConnectorProviderPostHog,
|
||||||
|
ConnectorProviderPostHogSelfHosted,
|
||||||
ConnectorProviderSentry,
|
ConnectorProviderSentry,
|
||||||
ConnectorProviderSupabase,
|
ConnectorProviderSupabase,
|
||||||
ConnectorProviderGitHub,
|
ConnectorProviderGitHub,
|
||||||
@@ -116,6 +123,7 @@ func (v ConnectorProvider) IsValid() bool {
|
|||||||
ConnectorProviderGrafana,
|
ConnectorProviderGrafana,
|
||||||
ConnectorProviderOpenAI,
|
ConnectorProviderOpenAI,
|
||||||
ConnectorProviderPostHog,
|
ConnectorProviderPostHog,
|
||||||
|
ConnectorProviderPostHogSelfHosted,
|
||||||
ConnectorProviderSentry,
|
ConnectorProviderSentry,
|
||||||
ConnectorProviderSupabase,
|
ConnectorProviderSupabase,
|
||||||
ConnectorProviderGitHub,
|
ConnectorProviderGitHub,
|
||||||
|
|||||||
@@ -91,6 +91,16 @@ type (
|
|||||||
MetabaseConnectorSettings struct {
|
MetabaseConnectorSettings struct {
|
||||||
InstanceURL string `json:"instance_url"`
|
InstanceURL string `json:"instance_url"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PostHogConnectorSettings carries the data-API base host for both the
|
||||||
|
// cloud (POSTHOG) and self-hosted (POSTHOG_SELF_HOSTED) providers. It
|
||||||
|
// is the region-pinned host for API-key connections
|
||||||
|
// (https://us.posthog.com / https://eu.posthog.com / a self-hosted
|
||||||
|
// instance URL). It is empty for cloud OAuth connections — the driver
|
||||||
|
// then defaults to the region-agnostic gateway https://oauth.posthog.com.
|
||||||
|
PostHogConnectorSettings struct {
|
||||||
|
BaseURL string `json:"base_url"`
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
// GrantType returns the OAuth2 grant type recorded on the connector's
|
// GrantType returns the OAuth2 grant type recorded on the connector's
|
||||||
|
|||||||
15
pkg/coredata/migrations/20260529T617403Z.sql
Normal file
15
pkg/coredata/migrations/20260529T617403Z.sql
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
-- Copyright (c) 2026 Probo Inc <hello@getprobo.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 'POSTHOG_SELF_HOSTED';
|
||||||
Reference in New Issue
Block a user