The connection-status probe and the access-review driver each had their own copy of the "try us.posthog.com, then eu.posthog.com" region-discovery loop, and they drifted. The driver skips a region that rejects the token (wrong region) and uses the one that answers 2xx; the probe instead returned "credential rejected" on the first region's 401/403, before ever trying the second. PostHog Cloud US and EU are separate deployments, so an EU OAuth token is a 401 on us.posthog.com, which is probed first. The probe bailed there and marked the source disconnected, while access-review campaigns -- which use the driver -- kept working. Delete the probe's copy and delegate to the driver's now-exported ResolvePostHogRegion, the single resolver the campaign also uses. It flags a credential every region rejected (ErrPostHogCredentialRejected) apart from a transient failure on the token's own region, so the probe marks a source disconnected only for a genuinely dead token and does not flap on a passing 5xx. Signed-off-by: Aurélien Sibiril <81782+aureliensibiril@users.noreply.github.com>
480 lines
14 KiB
Go
480 lines
14 KiB
Go
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
|
//
|
|
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
// of this software and associated documentation files (the "Software"), to deal
|
|
// in the Software without restriction, including without limitation the rights
|
|
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
// copies of the Software, and to permit persons to whom the Software is
|
|
// furnished to do so, subject to the following conditions:
|
|
//
|
|
// The above copyright notice and this permission notice shall be included in
|
|
// all copies or substantial portions of the Software.
|
|
//
|
|
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
// SOFTWARE.
|
|
|
|
package drivers
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"go.probo.inc/probo/pkg/coredata"
|
|
)
|
|
|
|
type PostHogDriver struct {
|
|
httpClient *http.Client
|
|
baseURL string
|
|
}
|
|
|
|
var _ Driver = (*PostHogDriver)(nil)
|
|
|
|
// PostHogOrganizationPath is the @current organization endpoint. It is the
|
|
// single source of truth shared with the connection probe so the two never
|
|
// duplicate the path.
|
|
const PostHogOrganizationPath = "/api/organizations/@current/"
|
|
|
|
const (
|
|
posthogMembersPath = "/api/organizations/@current/members/"
|
|
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
|
|
posthogMembershipLevelOwner = 15
|
|
)
|
|
|
|
// ErrPostHogCredentialRejected reports that every PostHog Cloud region refused
|
|
// the token with 401/403 — a definitively dead or revoked credential, as
|
|
// opposed to a transient failure (5xx/network) on the token's own region. The
|
|
// connection probe uses it to tell a rejected credential apart from an
|
|
// inconclusive result, which must not flap the source to disconnected.
|
|
var ErrPostHogCredentialRejected = errors.New("posthog rejected the credential on every region")
|
|
|
|
type (
|
|
posthogMembersResponse struct {
|
|
Next string `json:"next"`
|
|
Results []posthogMember `json:"results"`
|
|
}
|
|
|
|
posthogMember struct {
|
|
ID string `json:"id"`
|
|
User posthogMemberUser `json:"user"`
|
|
Level int `json:"level"`
|
|
Is2FAEnabled *bool `json:"is_2fa_enabled"`
|
|
JoinedAt string `json:"joined_at"`
|
|
LastLogin string `json:"last_login"`
|
|
}
|
|
|
|
posthogMemberUser struct {
|
|
UUID string `json:"uuid"`
|
|
FirstName string `json:"first_name"`
|
|
LastName string `json:"last_name"`
|
|
Email string `json:"email"`
|
|
RoleAtOrganization string `json:"role_at_organization"`
|
|
}
|
|
)
|
|
|
|
// 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) {
|
|
if err := d.resolveBaseURL(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
nextURL, err := d.membersURL()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var records []AccountRecord
|
|
|
|
for range maxPaginationPages {
|
|
resp, err := d.fetchMembers(ctx, nextURL)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
for _, member := range resp.Results {
|
|
record := posthogAccountRecord(member)
|
|
if record.Email == "" {
|
|
continue
|
|
}
|
|
|
|
records = append(records, record)
|
|
}
|
|
|
|
if resp.Next == "" {
|
|
return records, nil
|
|
}
|
|
|
|
nextURL, err = d.resolveNextURL(resp.Next)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
return nil, fmt.Errorf("cannot list all posthog accounts: %w", ErrPaginationLimitReached)
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
q := u.Query()
|
|
q.Set("limit", strconv.Itoa(posthogMembersPageSize))
|
|
q.Set("order", "-joined_at")
|
|
u.RawQuery = q.Encode()
|
|
|
|
return u.String(), nil
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
if nextURL.IsAbs() {
|
|
base, err := url.Parse(d.baseURL)
|
|
if err != nil {
|
|
return "", fmt.Errorf("cannot parse posthog base URL: %w", err)
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
return baseURL.ResolveReference(nextURL).String(), nil
|
|
}
|
|
|
|
// PostHogRegionBaseURL maps a PostHog Cloud region ("US"/"EU",
|
|
// case-insensitive) to its data-API host. It is the single source of truth for
|
|
// the regional hosts, shared with the connector-settings resolver so the two
|
|
// never drift. Self-hosted instances use a full instance URL instead.
|
|
func PostHogRegionBaseURL(region string) (string, bool) {
|
|
switch strings.ToLower(region) {
|
|
case "us":
|
|
return posthogUSBaseURL, true
|
|
case "eu":
|
|
return posthogEUBaseURL, true
|
|
default:
|
|
return "", false
|
|
}
|
|
}
|
|
|
|
// 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.
|
|
//
|
|
// A token is valid on exactly one region; the other rejects it with 401/403.
|
|
// The result distinguishes the two failure classes the connection probe needs:
|
|
// ErrPostHogCredentialRejected when every region refused the token (dead/revoked
|
|
// credential), and a generic error when a region was merely unreachable or
|
|
// errored transiently, which must not be read as a rejection.
|
|
func ResolvePostHogRegion(ctx context.Context, client *http.Client) (string, error) {
|
|
allRejected := true
|
|
|
|
for _, host := range []string{posthogUSBaseURL, posthogEUBaseURL} {
|
|
endpoint, err := url.JoinPath(host, PostHogOrganizationPath)
|
|
if err != nil {
|
|
allRejected = false
|
|
|
|
continue
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
|
if err != nil {
|
|
allRejected = false
|
|
|
|
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())
|
|
}
|
|
|
|
allRejected = false
|
|
|
|
continue
|
|
}
|
|
|
|
status := resp.StatusCode
|
|
_, _ = io.Copy(io.Discard, resp.Body)
|
|
_ = resp.Body.Close()
|
|
|
|
if status >= http.StatusOK && status < http.StatusMultipleChoices {
|
|
return host, nil
|
|
}
|
|
|
|
// Only 401/403 is a credential rejection; a 5xx/429 is transient and
|
|
// leaves the verdict inconclusive rather than rejected.
|
|
if status != http.StatusUnauthorized && status != http.StatusForbidden {
|
|
allRejected = false
|
|
}
|
|
}
|
|
|
|
if allRejected {
|
|
return "", fmt.Errorf("cannot resolve posthog region: %w", ErrPostHogCredentialRejected)
|
|
}
|
|
|
|
return "", fmt.Errorf("cannot resolve posthog region: no region accepted the connection")
|
|
}
|
|
|
|
func (d *PostHogDriver) fetchMembers(
|
|
ctx context.Context,
|
|
nextURL string,
|
|
) (*posthogMembersResponse, error) {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, nextURL, nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("cannot create posthog 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 posthog members request: %w", err)
|
|
}
|
|
|
|
defer func() {
|
|
_ = httpResp.Body.Close()
|
|
}()
|
|
|
|
if httpResp.StatusCode < http.StatusOK || httpResp.StatusCode >= http.StatusMultipleChoices {
|
|
return nil, fmt.Errorf("cannot fetch posthog members: unexpected status %d", httpResp.StatusCode)
|
|
}
|
|
|
|
var resp posthogMembersResponse
|
|
if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil {
|
|
return nil, fmt.Errorf("cannot decode posthog members response: %w", err)
|
|
}
|
|
|
|
return &resp, nil
|
|
}
|
|
|
|
func posthogAccountRecord(member posthogMember) AccountRecord {
|
|
record := AccountRecord{
|
|
Email: member.User.Email,
|
|
FullName: posthogFullName(member.User),
|
|
Roles: posthogRoles(member.Level, member.User.RoleAtOrganization),
|
|
IsAdmin: posthogIsAdmin(member.Level),
|
|
ExternalID: member.User.UUID,
|
|
MFAStatus: posthogMFAStatus(member.Is2FAEnabled),
|
|
AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown,
|
|
AccountType: coredata.AccessReviewEntryAccountTypeUser,
|
|
}
|
|
|
|
if record.ExternalID == "" {
|
|
record.ExternalID = member.ID
|
|
}
|
|
|
|
if t, ok := parseRFC3339(member.JoinedAt); ok {
|
|
record.CreatedAt = &t
|
|
}
|
|
|
|
if t, ok := parseRFC3339(member.LastLogin); ok {
|
|
record.LastLogin = &t
|
|
}
|
|
|
|
return record
|
|
}
|
|
|
|
func posthogFullName(user posthogMemberUser) string {
|
|
return strings.TrimSpace(strings.Join([]string{user.FirstName, user.LastName}, " "))
|
|
}
|
|
|
|
func posthogRoles(level int, fallback string) []string {
|
|
switch {
|
|
case level >= posthogMembershipLevelOwner:
|
|
return []string{"Owner"}
|
|
case level >= posthogMembershipLevelAdmin:
|
|
return []string{"Admin"}
|
|
case level >= posthogMembershipLevelMember:
|
|
return []string{"Member"}
|
|
case fallback != "":
|
|
return []string{fallback}
|
|
default:
|
|
return []string{"Member"}
|
|
}
|
|
}
|
|
|
|
func posthogIsAdmin(level int) bool {
|
|
return level >= posthogMembershipLevelAdmin
|
|
}
|
|
|
|
func posthogMFAStatus(twoFAEnabled *bool) coredata.MFAStatus {
|
|
if twoFAEnabled == nil {
|
|
return coredata.MFAStatusUnknown
|
|
}
|
|
|
|
if *twoFAEnabled {
|
|
return coredata.MFAStatusEnabled
|
|
}
|
|
|
|
return coredata.MFAStatusDisabled
|
|
}
|
|
|
|
// posthogNameResolver resolves the PostHog organization name from the
|
|
// current organization endpoint, which returns the org the connection
|
|
// belongs to.
|
|
type posthogNameResolver struct {
|
|
httpClient *http.Client
|
|
baseURL string
|
|
}
|
|
|
|
var _ NameResolver = (*posthogNameResolver)(nil)
|
|
|
|
// 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,
|
|
endpoint,
|
|
nil,
|
|
)
|
|
if err != nil {
|
|
return "", fmt.Errorf("cannot create posthog organization request: %w", err)
|
|
}
|
|
|
|
req.Header.Set("Accept", "application/json")
|
|
|
|
httpResp, err := r.httpClient.Do(req)
|
|
if err != nil {
|
|
return "", fmt.Errorf("cannot execute posthog organization request: %w", err)
|
|
}
|
|
|
|
defer func() { _ = httpResp.Body.Close() }()
|
|
|
|
// 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 < http.StatusOK || httpResp.StatusCode >= http.StatusMultipleChoices {
|
|
return "", nil
|
|
}
|
|
|
|
var resp struct {
|
|
Name string `json:"name"`
|
|
}
|
|
if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil {
|
|
return "", fmt.Errorf("cannot decode posthog organization response: %w", err)
|
|
}
|
|
|
|
return resp.Name, nil
|
|
}
|
|
|
|
func parseRFC3339(value string) (time.Time, bool) {
|
|
if value == "" {
|
|
return time.Time{}, false
|
|
}
|
|
|
|
t, err := time.Parse(time.RFC3339, value)
|
|
if err != nil {
|
|
return time.Time{}, false
|
|
}
|
|
|
|
return t, true
|
|
}
|