diff --git a/pkg/accessreview/drivers/posthog.go b/pkg/accessreview/drivers/posthog.go index 8da9fa982..178597331 100644 --- a/pkg/accessreview/drivers/posthog.go +++ b/pkg/accessreview/drivers/posthog.go @@ -34,8 +34,9 @@ type PostHogDriver struct { var _ Driver = (*PostHogDriver)(nil) const ( - posthogMembersEndpoint = "https://app.posthog.com/api/organizations/@current/members/" - posthogMembersPageSize = 100 + posthogMembersEndpoint = "https://app.posthog.com/api/organizations/@current/members/" + posthogOrganizationEndpoint = "https://app.posthog.com/api/organizations/@current/" + posthogMembersPageSize = 100 posthogMembershipLevelMember = 1 posthogMembershipLevelAdmin = 8 @@ -232,6 +233,55 @@ func posthogMFAStatus(twoFAEnabled *bool) coredata.MFAStatus { return coredata.MFAStatusDisabled } +// posthogNameResolver resolves the PostHog organization name from the +// current organization endpoint, which returns the org an API key belongs to. +type posthogNameResolver struct { + httpClient *http.Client +} + +var _ NameResolver = (*posthogNameResolver)(nil) + +func NewPostHogNameResolver(httpClient *http.Client) NameResolver { + return &posthogNameResolver{httpClient: httpClient} +} + +func (r *posthogNameResolver) ResolveInstanceName(ctx context.Context) (string, error) { + req, err := http.NewRequestWithContext( + ctx, + http.MethodGet, + posthogOrganizationEndpoint, + 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 < 200 || httpResp.StatusCode >= 300 { + 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 diff --git a/pkg/accessreview/drivers/posthog_test.go b/pkg/accessreview/drivers/posthog_test.go index 3ac994863..d87a392e3 100644 --- a/pkg/accessreview/drivers/posthog_test.go +++ b/pkg/accessreview/drivers/posthog_test.go @@ -16,6 +16,8 @@ package drivers import ( "context" + "net/http" + "net/http/httptest" "os" "testing" @@ -61,6 +63,63 @@ func TestPostHogDriverListAccounts(t *testing.T) { require.NotNil(t, admin.CreatedAt) } +func TestPostHogNameResolver(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + status int + body string + want string + wantErr bool + }{ + { + name: "200 returns name", + status: http.StatusOK, + body: `{"id":"org-1","name":"Acme Inc","slug":"acme"}`, + want: "Acme Inc", + }, + { + name: "401 is terminal (no error, no name)", + status: http.StatusUnauthorized, + body: `{"detail":"Authentication credentials were not provided."}`, + want: "", + }, + { + name: "404 is terminal (no error, no name)", + status: http.StatusNotFound, + body: `{"detail":"Not found."}`, + want: "", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + 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/", r.URL.Path) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(tc.status) + _, _ = w.Write([]byte(tc.body)) + })) + defer srv.Close() + + client := &http.Client{Transport: &hostRewriter{target: srv.URL}} + + got, err := NewPostHogNameResolver(client).ResolveInstanceName(context.Background()) + if tc.wantErr { + require.Error(t, err) + return + } + + require.NoError(t, err) + assert.Equal(t, tc.want, got) + }) + } +} + func TestPostHogRoleFallback(t *testing.T) { t.Parallel() diff --git a/pkg/connector/provider/posthog.go b/pkg/connector/provider/posthog.go index 0a19fc6ba..0169014b6 100644 --- a/pkg/connector/provider/posthog.go +++ b/pkg/connector/provider/posthog.go @@ -31,5 +31,8 @@ func posthogRegistration() *Registration { NewDriver: func(_ context.Context, c *http.Client, _ *coredata.Connector, _ *log.Logger) (drivers.Driver, error) { return drivers.NewPostHogDriver(c), nil }, + NewNameResolver: func(_ context.Context, c *http.Client, _ *coredata.Connector, _ *log.Logger) drivers.NameResolver { + return drivers.NewPostHogNameResolver(c) + }, } } diff --git a/pkg/coredata/migrations/20260529T022423Z.sql b/pkg/coredata/migrations/20260529T022423Z.sql new file mode 100644 index 000000000..14cc26455 --- /dev/null +++ b/pkg/coredata/migrations/20260529T022423Z.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';