diff --git a/pkg/accessreview/drivers/posthog.go b/pkg/accessreview/drivers/posthog.go new file mode 100644 index 000000000..8da9fa982 --- /dev/null +++ b/pkg/accessreview/drivers/posthog.go @@ -0,0 +1,246 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package drivers + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "go.probo.inc/probo/pkg/coredata" +) + +type PostHogDriver struct { + httpClient *http.Client +} + +var _ Driver = (*PostHogDriver)(nil) + +const ( + posthogMembersEndpoint = "https://app.posthog.com/api/organizations/@current/members/" + posthogMembersPageSize = 100 + + posthogMembershipLevelMember = 1 + posthogMembershipLevelAdmin = 8 + posthogMembershipLevelOwner = 15 +) + +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"` + } +) + +func NewPostHogDriver(httpClient *http.Client) *PostHogDriver { + return &PostHogDriver{httpClient: httpClient} +} + +func (d *PostHogDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error) { + nextURL, err := buildPostHogMembersURL() + 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 = resolvePostHogNextURL(resp.Next) + if err != nil { + return nil, err + } + } + + return nil, fmt.Errorf("cannot list all posthog accounts: %w", ErrPaginationLimitReached) +} + +func buildPostHogMembersURL() (string, error) { + u, err := url.Parse(posthogMembersEndpoint) + 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 resolvePostHogNextURL(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() { + return nextURL.String(), nil + } + + baseURL, err := url.Parse(posthogMembersEndpoint) + if err != nil { + return "", fmt.Errorf("cannot parse posthog members base URL: %w", err) + } + + return baseURL.ResolveReference(nextURL).String(), nil +} + +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 < 200 || httpResp.StatusCode >= 300 { + 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), + Role: posthogRole(member.Level, member.User.RoleAtOrganization), + IsAdmin: posthogIsAdmin(member.Level), + ExternalID: member.User.UUID, + MFAStatus: posthogMFAStatus(member.Is2FAEnabled), + AuthMethod: coredata.AccessEntryAuthMethodUnknown, + AccountType: coredata.AccessEntryAccountTypeUser, + } + + 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 posthogRole(level int, fallback string) string { + switch { + case level >= posthogMembershipLevelOwner: + return "Owner" + case level >= posthogMembershipLevelAdmin: + return "Admin" + case level >= posthogMembershipLevelMember: + return "Member" + case fallback != "": + return fallback + default: + return "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 +} + +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 +} diff --git a/pkg/accessreview/drivers/posthog_test.go b/pkg/accessreview/drivers/posthog_test.go new file mode 100644 index 000000000..dd6c60934 --- /dev/null +++ b/pkg/accessreview/drivers/posthog_test.go @@ -0,0 +1,136 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package drivers + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.probo.inc/probo/pkg/coredata" +) + +func TestPostHogDriverListAccounts(t *testing.T) { + t.Parallel() + + var srv *httptest.Server + srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method) + assert.Equal(t, "/api/organizations/@current/members/", r.URL.Path) + assert.Equal(t, "100", r.URL.Query().Get("limit")) + assert.Equal(t, "-joined_at", r.URL.Query().Get("order")) + w.Header().Set("Content-Type", "application/json") + + switch r.URL.Query().Get("offset") { + case "": + _, _ = w.Write([]byte(`{ + "next": "` + srv.URL + `/api/organizations/@current/members/?limit=100&order=-joined_at&offset=2", + "results": [ + { + "id": "membership-1", + "user": { + "uuid": "user-1", + "first_name": "Olivia", + "last_name": "Owner", + "email": "owner@example.com" + }, + "level": 15, + "is_2fa_enabled": true, + "joined_at": "2025-01-10T12:00:00Z", + "last_login": "2025-05-01T09:00:00Z" + }, + { + "id": "membership-2", + "user": { + "uuid": "user-2", + "first_name": "Maya", + "last_name": "Member", + "email": "member@example.com" + }, + "level": 1, + "is_2fa_enabled": false + } + ] +}`)) + case "2": + _, _ = w.Write([]byte(`{ + "next": "", + "results": [ + { + "id": "membership-3", + "user": { + "uuid": "", + "first_name": "Ari", + "last_name": "Admin", + "email": "admin@example.com" + }, + "level": 8, + "joined_at": "2024-02-20T00:00:00Z" + } + ] +}`)) + default: + t.Fatalf("unexpected offset query value %q", r.URL.Query().Get("offset")) + } + })) + defer srv.Close() + + client := &http.Client{ + Transport: &hostRewriter{target: srv.URL}, + } + + records, err := NewPostHogDriver(client).ListAccounts(context.Background()) + require.NoError(t, err) + require.Len(t, records, 3) + + owner := records[0] + assert.Equal(t, "owner@example.com", owner.Email) + assert.Equal(t, "Olivia Owner", owner.FullName) + assert.Equal(t, "Owner", owner.Role) + assert.True(t, owner.IsAdmin) + assert.Equal(t, coredata.MFAStatusEnabled, owner.MFAStatus) + assert.Equal(t, "user-1", owner.ExternalID) + require.NotNil(t, owner.CreatedAt) + require.NotNil(t, owner.LastLogin) + + member := records[1] + assert.Equal(t, "member@example.com", member.Email) + assert.Equal(t, "Member", member.Role) + assert.False(t, member.IsAdmin) + assert.Equal(t, coredata.MFAStatusDisabled, member.MFAStatus) + assert.Nil(t, member.CreatedAt) + assert.Nil(t, member.LastLogin) + + admin := records[2] + assert.Equal(t, "admin@example.com", admin.Email) + assert.Equal(t, "Admin", admin.Role) + assert.True(t, admin.IsAdmin) + assert.Equal(t, coredata.MFAStatusUnknown, admin.MFAStatus) + assert.Equal(t, "membership-3", admin.ExternalID) + require.NotNil(t, admin.CreatedAt) +} + +func TestPostHogRoleFallback(t *testing.T) { + t.Parallel() + + assert.Equal(t, "Owner", posthogRole(15, "")) + assert.Equal(t, "Admin", posthogRole(8, "")) + assert.Equal(t, "Member", posthogRole(1, "")) + assert.Equal(t, "engineering", posthogRole(0, "engineering")) + assert.Equal(t, "Member", posthogRole(0, "")) +} diff --git a/pkg/connector/provider/builtin.go b/pkg/connector/provider/builtin.go index 46f7b66e0..cb0128dd1 100644 --- a/pkg/connector/provider/builtin.go +++ b/pkg/connector/provider/builtin.go @@ -45,6 +45,7 @@ func NewBuiltinRegistry() *Registry { notionRegistration(), onePasswordRegistration(), openaiRegistration(), + posthogRegistration(), pagerdutyRegistration(), resendRegistration(), sentryRegistration(), diff --git a/pkg/connector/provider/posthog.go b/pkg/connector/provider/posthog.go new file mode 100644 index 000000000..0a19fc6ba --- /dev/null +++ b/pkg/connector/provider/posthog.go @@ -0,0 +1,35 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package provider + +import ( + "context" + "net/http" + + "go.gearno.de/kit/log" + "go.probo.inc/probo/pkg/accessreview/drivers" + "go.probo.inc/probo/pkg/coredata" +) + +func posthogRegistration() *Registration { + return &Registration{ + Provider: coredata.ConnectorProviderPostHog, + DisplayName: "PostHog", + SupportsAPIKey: true, + NewDriver: func(_ context.Context, c *http.Client, _ *coredata.Connector, _ *log.Logger) (drivers.Driver, error) { + return drivers.NewPostHogDriver(c), nil + }, + } +} diff --git a/pkg/coredata/connector_provider.go b/pkg/coredata/connector_provider.go index c6da291a3..3dc6915e8 100644 --- a/pkg/coredata/connector_provider.go +++ b/pkg/coredata/connector_provider.go @@ -35,6 +35,7 @@ const ( ConnectorProviderCloudflare ConnectorProvider = "CLOUDFLARE" ConnectorProviderGrafana ConnectorProvider = "GRAFANA" ConnectorProviderOpenAI ConnectorProvider = "OPENAI" + ConnectorProviderPostHog ConnectorProvider = "POSTHOG" ConnectorProviderSentry ConnectorProvider = "SENTRY" ConnectorProviderSupabase ConnectorProvider = "SUPABASE" ConnectorProviderGitHub ConnectorProvider = "GITHUB" @@ -76,6 +77,7 @@ func ConnectorProviders() []ConnectorProvider { ConnectorProviderCloudflare, ConnectorProviderGrafana, ConnectorProviderOpenAI, + ConnectorProviderPostHog, ConnectorProviderSentry, ConnectorProviderSupabase, ConnectorProviderGitHub, @@ -113,6 +115,7 @@ func (v ConnectorProvider) IsValid() bool { ConnectorProviderCloudflare, ConnectorProviderGrafana, ConnectorProviderOpenAI, + ConnectorProviderPostHog, ConnectorProviderSentry, ConnectorProviderSupabase, ConnectorProviderGitHub, diff --git a/pkg/server/api/console/v1/graphql/connector.graphql b/pkg/server/api/console/v1/graphql/connector.graphql index 4a867b506..1096a8e92 100644 --- a/pkg/server/api/console/v1/graphql/connector.graphql +++ b/pkg/server/api/console/v1/graphql/connector.graphql @@ -22,6 +22,7 @@ enum ConnectorProvider CLOUDFLARE @goEnum(value: "go.probo.inc/probo/pkg/coredata.ConnectorProviderCloudflare") OPENAI @goEnum(value: "go.probo.inc/probo/pkg/coredata.ConnectorProviderOpenAI") + POSTHOG @goEnum(value: "go.probo.inc/probo/pkg/coredata.ConnectorProviderPostHog") SENTRY @goEnum(value: "go.probo.inc/probo/pkg/coredata.ConnectorProviderSentry") SUPABASE @goEnum(value: "go.probo.inc/probo/pkg/coredata.ConnectorProviderSupabase")