diff --git a/.env.example b/.env.example index 5a2f0b705..ad7804d6d 100644 --- a/.env.example +++ b/.env.example @@ -122,6 +122,10 @@ # CONNECTOR_VERCEL_INTEGRATION_SLUG= # CONNECTOR_MONDAY_CLIENT_ID= # 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_CNAME_TARGET=custom.getprobo.com diff --git a/pkg/accessreview/drivers/posthog.go b/pkg/accessreview/drivers/posthog.go index 178597331..9f4943239 100644 --- a/pkg/accessreview/drivers/posthog.go +++ b/pkg/accessreview/drivers/posthog.go @@ -29,14 +29,24 @@ import ( type PostHogDriver struct { httpClient *http.Client + baseURL string } var _ Driver = (*PostHogDriver)(nil) const ( - posthogMembersEndpoint = "https://app.posthog.com/api/organizations/@current/members/" - posthogOrganizationEndpoint = "https://app.posthog.com/api/organizations/@current/" - posthogMembersPageSize = 100 + posthogMembersPath = "/api/organizations/@current/members/" + posthogOrganizationPath = "/api/organizations/@current/" + 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 posthogMembershipLevelAdmin = 8 @@ -67,12 +77,39 @@ type ( } ) -func NewPostHogDriver(httpClient *http.Client) *PostHogDriver { - return &PostHogDriver{httpClient: httpClient} +// NewPostHogDriver builds a driver against baseURL (e.g. https://us.posthog.com +// 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) { - nextURL, err := buildPostHogMembersURL() + if err := d.resolveBaseURL(ctx); err != nil { + return nil, err + } + + nextURL, err := d.membersURL() if err != nil { return nil, err } @@ -98,7 +135,7 @@ func (d *PostHogDriver) ListAccounts(ctx context.Context) ([]AccountRecord, erro return records, nil } - nextURL, err = resolvePostHogNextURL(resp.Next) + nextURL, err = d.resolveNextURL(resp.Next) if err != nil { 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) } -func buildPostHogMembersURL() (string, error) { - u, err := url.Parse(posthogMembersEndpoint) +func (d *PostHogDriver) membersURL() (string, error) { + 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 { return "", fmt.Errorf("cannot parse posthog members URL: %w", err) } @@ -121,17 +163,36 @@ func buildPostHogMembersURL() (string, error) { return u.String(), nil } -func resolvePostHogNextURL(next string) (string, error) { +func (d *PostHogDriver) resolveNextURL(next string) (string, error) { nextURL, err := url.Parse(next) if err != nil { 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() { + // 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 } - 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 { 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 } +// 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( ctx context.Context, nextURL string, @@ -159,7 +261,7 @@ func (d *PostHogDriver) fetchMembers( _ = 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) } @@ -234,22 +336,44 @@ func posthogMFAStatus(twoFAEnabled *bool) coredata.MFAStatus { } // 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 { httpClient *http.Client + baseURL string } var _ NameResolver = (*posthogNameResolver)(nil) -func NewPostHogNameResolver(httpClient *http.Client) NameResolver { - return &posthogNameResolver{httpClient: httpClient} +// NewPostHogNameResolver resolves the org name against baseURL. An empty +// 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) { + 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( ctx, http.MethodGet, - posthogOrganizationEndpoint, + endpoint, 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 // source-name worker retry forever. Give up gracefully and keep the // 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 } diff --git a/pkg/accessreview/drivers/posthog_test.go b/pkg/accessreview/drivers/posthog_test.go index d87a392e3..eb0e82e55 100644 --- a/pkg/accessreview/drivers/posthog_test.go +++ b/pkg/accessreview/drivers/posthog_test.go @@ -16,9 +16,11 @@ package drivers import ( "context" + "io" "net/http" "net/http/httptest" "os" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -32,7 +34,7 @@ func TestPostHogDriverListAccounts(t *testing.T) { rec := newRecorder(t, "testdata/posthog", "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.Len(t, records, 3) @@ -63,6 +65,61 @@ func TestPostHogDriverListAccounts(t *testing.T) { 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) { t.Parallel() @@ -108,7 +165,7 @@ func TestPostHogNameResolver(t *testing.T) { 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 { require.Error(t, err) return diff --git a/pkg/connector/provider/apply_test.go b/pkg/connector/provider/apply_test.go index ca9927895..56defdcc6 100644 --- a/pkg/connector/provider/apply_test.go +++ b/pkg/connector/provider/apply_test.go @@ -86,7 +86,7 @@ func TestApplyOAuth2Defaults_AuthURLFromSlug(t *testing.T) { func TestApplyOAuth2Defaults_PKCEDefaults(t *testing.T) { t.Parallel() - for _, p := range []string{"PAGERDUTY"} { + for _, p := range []string{"PAGERDUTY", "POSTHOG"} { t.Run(p, func(t *testing.T) { 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") +} diff --git a/pkg/connector/provider/builtin.go b/pkg/connector/provider/builtin.go index cb0128dd1..1f680b761 100644 --- a/pkg/connector/provider/builtin.go +++ b/pkg/connector/provider/builtin.go @@ -46,6 +46,7 @@ func NewBuiltinRegistry() *Registry { onePasswordRegistration(), openaiRegistration(), posthogRegistration(), + posthogSelfHostedRegistration(), pagerdutyRegistration(), resendRegistration(), sentryRegistration(), diff --git a/pkg/connector/provider/posthog.go b/pkg/connector/provider/posthog.go index 0169014b6..465087fd4 100644 --- a/pkg/connector/provider/posthog.go +++ b/pkg/connector/provider/posthog.go @@ -16,6 +16,7 @@ package provider import ( "context" + "fmt" "net/http" "go.gearno.de/kit/log" @@ -23,16 +24,61 @@ import ( "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 { return &Registration{ - Provider: coredata.ConnectorProviderPostHog, - DisplayName: "PostHog", + Provider: coredata.ConnectorProviderPostHog, + 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, - NewDriver: func(_ context.Context, c *http.Client, _ *coredata.Connector, _ *log.Logger) (drivers.Driver, error) { - return drivers.NewPostHogDriver(c), nil + ExtraSettings: []ExtraSetting{ + {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) }, } } diff --git a/pkg/connector/provider/posthog_self_hosted.go b/pkg/connector/provider/posthog_self_hosted.go new file mode 100644 index 000000000..2ab535ae4 --- /dev/null +++ b/pkg/connector/provider/posthog_self_hosted.go @@ -0,0 +1,71 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package provider + +import ( + "context" + "fmt" + "net/http" + + "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) + }, + } +} diff --git a/pkg/coredata/connector_provider.go b/pkg/coredata/connector_provider.go index 3dc6915e8..df0ffe073 100644 --- a/pkg/coredata/connector_provider.go +++ b/pkg/coredata/connector_provider.go @@ -26,35 +26,41 @@ const ( ConnectorProviderGoogleWorkspace ConnectorProvider = "GOOGLE_WORKSPACE" ConnectorProviderLinear ConnectorProvider = "LINEAR" // _ ConnectorProvider = "FIGMA" — formerly Figma; removed (no driver, no OAuth config, no usage) - ConnectorProviderOnePassword ConnectorProvider = "ONE_PASSWORD" - ConnectorProviderHubSpot ConnectorProvider = "HUBSPOT" - ConnectorProviderDocuSign ConnectorProvider = "DOCUSIGN" - ConnectorProviderNotion ConnectorProvider = "NOTION" - ConnectorProviderBrex ConnectorProvider = "BREX" - ConnectorProviderTally ConnectorProvider = "TALLY" - ConnectorProviderCloudflare ConnectorProvider = "CLOUDFLARE" - ConnectorProviderGrafana ConnectorProvider = "GRAFANA" - ConnectorProviderOpenAI ConnectorProvider = "OPENAI" - ConnectorProviderPostHog ConnectorProvider = "POSTHOG" - ConnectorProviderSentry ConnectorProvider = "SENTRY" - ConnectorProviderSupabase ConnectorProvider = "SUPABASE" - ConnectorProviderGitHub ConnectorProvider = "GITHUB" - ConnectorProviderIntercom ConnectorProvider = "INTERCOM" - ConnectorProviderResend ConnectorProvider = "RESEND" - ConnectorProviderMicrosoft365 ConnectorProvider = "MICROSOFT_365" - ConnectorProviderGitLab ConnectorProvider = "GITLAB" - ConnectorProviderBitbucket ConnectorProvider = "BITBUCKET" - ConnectorProviderHeroku ConnectorProvider = "HEROKU" - ConnectorProviderPagerDuty ConnectorProvider = "PAGERDUTY" - ConnectorProviderAsana ConnectorProvider = "ASANA" - ConnectorProviderNetlify ConnectorProvider = "NETLIFY" - ConnectorProviderClickUp ConnectorProvider = "CLICKUP" - ConnectorProviderVercel ConnectorProvider = "VERCEL" - ConnectorProviderMonday ConnectorProvider = "MONDAY" - ConnectorProviderMetabase ConnectorProvider = "METABASE" - ConnectorProviderTailscale ConnectorProvider = "TAILSCALE" - ConnectorProviderAnthropic ConnectorProvider = "ANTHROPIC" - ConnectorProviderCursor ConnectorProvider = "CURSOR" + ConnectorProviderOnePassword ConnectorProvider = "ONE_PASSWORD" + ConnectorProviderHubSpot ConnectorProvider = "HUBSPOT" + ConnectorProviderDocuSign ConnectorProvider = "DOCUSIGN" + ConnectorProviderNotion ConnectorProvider = "NOTION" + ConnectorProviderBrex ConnectorProvider = "BREX" + ConnectorProviderTally ConnectorProvider = "TALLY" + ConnectorProviderCloudflare ConnectorProvider = "CLOUDFLARE" + ConnectorProviderGrafana ConnectorProvider = "GRAFANA" + ConnectorProviderOpenAI ConnectorProvider = "OPENAI" + ConnectorProviderPostHog ConnectorProvider = "POSTHOG" + // ConnectorProviderPostHogSelfHosted is a distinct provider for + // customer-hosted PostHog instances: API-key + operator-supplied + // instance URL. Cloud PostHog (POSTHOG) carries the OAuth path; a + // separate provider keeps the per-connection instance URL out of the + // shared OAuth flow. + ConnectorProviderPostHogSelfHosted ConnectorProvider = "POSTHOG_SELF_HOSTED" + ConnectorProviderSentry ConnectorProvider = "SENTRY" + ConnectorProviderSupabase ConnectorProvider = "SUPABASE" + ConnectorProviderGitHub ConnectorProvider = "GITHUB" + ConnectorProviderIntercom ConnectorProvider = "INTERCOM" + ConnectorProviderResend ConnectorProvider = "RESEND" + ConnectorProviderMicrosoft365 ConnectorProvider = "MICROSOFT_365" + ConnectorProviderGitLab ConnectorProvider = "GITLAB" + ConnectorProviderBitbucket ConnectorProvider = "BITBUCKET" + ConnectorProviderHeroku ConnectorProvider = "HEROKU" + ConnectorProviderPagerDuty ConnectorProvider = "PAGERDUTY" + ConnectorProviderAsana ConnectorProvider = "ASANA" + ConnectorProviderNetlify ConnectorProvider = "NETLIFY" + ConnectorProviderClickUp ConnectorProvider = "CLICKUP" + ConnectorProviderVercel ConnectorProvider = "VERCEL" + ConnectorProviderMonday ConnectorProvider = "MONDAY" + ConnectorProviderMetabase ConnectorProvider = "METABASE" + ConnectorProviderTailscale ConnectorProvider = "TAILSCALE" + ConnectorProviderAnthropic ConnectorProvider = "ANTHROPIC" + ConnectorProviderCursor ConnectorProvider = "CURSOR" ) var ( @@ -78,6 +84,7 @@ func ConnectorProviders() []ConnectorProvider { ConnectorProviderGrafana, ConnectorProviderOpenAI, ConnectorProviderPostHog, + ConnectorProviderPostHogSelfHosted, ConnectorProviderSentry, ConnectorProviderSupabase, ConnectorProviderGitHub, @@ -116,6 +123,7 @@ func (v ConnectorProvider) IsValid() bool { ConnectorProviderGrafana, ConnectorProviderOpenAI, ConnectorProviderPostHog, + ConnectorProviderPostHogSelfHosted, ConnectorProviderSentry, ConnectorProviderSupabase, ConnectorProviderGitHub, diff --git a/pkg/coredata/connector_settings.go b/pkg/coredata/connector_settings.go index 985d9630d..e0af16c28 100644 --- a/pkg/coredata/connector_settings.go +++ b/pkg/coredata/connector_settings.go @@ -91,6 +91,16 @@ type ( MetabaseConnectorSettings struct { 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 diff --git a/pkg/coredata/migrations/20260529T617403Z.sql b/pkg/coredata/migrations/20260529T617403Z.sql new file mode 100644 index 000000000..3c526722e --- /dev/null +++ b/pkg/coredata/migrations/20260529T617403Z.sql @@ -0,0 +1,15 @@ +-- Copyright (c) 2026 Probo Inc . +-- +-- Permission to use, copy, modify, and/or distribute this software for any +-- purpose with or without fee is hereby granted, provided that the above +-- copyright notice and this permission notice appear in all copies. +-- +-- THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +-- REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +-- AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +-- INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +-- LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +-- OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +-- PERFORMANCE OF THIS SOFTWARE. + +ALTER TYPE connector_provider ADD VALUE IF NOT EXISTS 'POSTHOG_SELF_HOSTED';