From 245a5d8f79c42fe26604f170c03e926cdfc91fec Mon Sep 17 00:00:00 2001 From: JonathanLab Date: Wed, 1 Oct 2025 14:42:31 +0200 Subject: [PATCH 1/3] feat: allow reading github integrations with personal API key --- frontend/src/lib/scopes.tsx | 1 + frontend/src/types.ts | 1 + posthog/api/integration.py | 9 ++++++++- posthog/scopes.py | 1 + 4 files changed, 11 insertions(+), 1 deletion(-) diff --git a/frontend/src/lib/scopes.tsx b/frontend/src/lib/scopes.tsx index 4f43df814962..673ca184c55f 100644 --- a/frontend/src/lib/scopes.tsx +++ b/frontend/src/lib/scopes.tsx @@ -31,6 +31,7 @@ export const API_SCOPES: APIScope[] = [ { key: 'group', objectPlural: 'groups' }, { key: 'hog_function', objectPlural: 'hog functions' }, { key: 'insight', objectPlural: 'insights' }, + { key: 'integration', disabledActions: ['write'], objectPlural: 'integrations' }, { key: 'notebook', objectPlural: 'notebooks' }, { key: 'organization', disabledWhenProjectScoped: true, objectPlural: 'organizations' }, { diff --git a/frontend/src/types.ts b/frontend/src/types.ts index bfb85e39e9e8..62f0fa9e5371 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -4506,6 +4506,7 @@ export type APIScopeObject = | 'group' | 'hog_function' | 'insight' + | 'integration' | 'notebook' | 'organization' | 'organization_member' diff --git a/posthog/api/integration.py b/posthog/api/integration.py index 26cbd65912f1..53c38e4a9095 100644 --- a/posthog/api/integration.py +++ b/posthog/api/integration.py @@ -17,6 +17,7 @@ from posthog.api.routing import TeamAndOrgViewSetMixin from posthog.api.shared import UserBasicSerializer from posthog.api.utils import action +from posthog.auth import PersonalAPIKeyAuthentication from posthog.models.instance_setting import get_instance_setting from posthog.models.integration import ( ClickUpIntegration, @@ -154,10 +155,16 @@ class IntegrationViewSet( mixins.DestroyModelMixin, viewsets.GenericViewSet, ): - scope_object = "INTERNAL" + scope_object = "integration" + scope_object_read_actions = ["list", "retrieve", "github_repos"] queryset = Integration.objects.all() serializer_class = IntegrationSerializer + def safely_get_queryset(self, queryset): + if isinstance(self.request.successful_authenticator, PersonalAPIKeyAuthentication): + return queryset.filter(kind="github") + return queryset + @action(methods=["GET"], detail=False) def authorize(self, request: Request, *args: Any, **kwargs: Any) -> HttpResponse: kind = request.GET.get("kind") diff --git a/posthog/scopes.py b/posthog/scopes.py index c36b696046a3..f17e66200ff3 100644 --- a/posthog/scopes.py +++ b/posthog/scopes.py @@ -28,6 +28,7 @@ "group", "hog_function", "insight", + "integration", "link", "named_query", "notebook", From 281cc53e9887b842d96255f7289ca9be7e61d07d Mon Sep 17 00:00:00 2001 From: github-actions <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 1 Oct 2025 12:55:06 +0000 Subject: [PATCH 2/3] Update query snapshots --- posthog/api/test/__snapshots__/test_api_docs.ambr | 1 + 1 file changed, 1 insertion(+) diff --git a/posthog/api/test/__snapshots__/test_api_docs.ambr b/posthog/api/test/__snapshots__/test_api_docs.ambr index a71cd8e3e91f..6ee7285bec23 100644 --- a/posthog/api/test/__snapshots__/test_api_docs.ambr +++ b/posthog/api/test/__snapshots__/test_api_docs.ambr @@ -65,6 +65,7 @@ '/home/runner/work/posthog/posthog/posthog/api/insight.py: Warning [EnterpriseInsightsViewSet > InsightSerializer]: unable to resolve type hint for function "get_result". Consider using a type hint or @extend_schema_field. Defaulting to string.', '/home/runner/work/posthog/posthog/posthog/api/insight.py: Warning [EnterpriseInsightsViewSet > InsightSerializer]: unable to resolve type hint for function "get_timezone". Consider using a type hint or @extend_schema_field. Defaulting to string.', '/home/runner/work/posthog/posthog/posthog/api/insight.py: Warning [EnterpriseInsightsViewSet > InsightSerializer]: unable to resolve type hint for function "get_types". Consider using a type hint or @extend_schema_field. Defaulting to string.', + '/home/runner/work/posthog/posthog/posthog/api/integration.py: Warning [IntegrationViewSet]: could not derive type of path parameter "project_id" because model "posthog.models.integration.Integration" contained no such field. Consider annotating parameter with @extend_schema. Defaulting to "string".', "/home/runner/work/posthog/posthog/posthog/api/named_query.py: Error [NamedQueryViewSet]: exception raised while getting serializer. Hint: Is get_serializer_class() returning None or is get_queryset() not working without a request? Ignoring the view for now. (Exception: 'NoneType' object is not callable)", '/home/runner/work/posthog/posthog/posthog/api/named_query.py: Warning [NamedQueryViewSet]: could not derive type of path parameter "project_id" because model "posthog.models.named_query.NamedQuery" contained no such field. Consider annotating parameter with @extend_schema. Defaulting to "string".', '/home/runner/work/posthog/posthog/posthog/api/notebook.py: Warning [NotebookViewSet]: could not derive type of path parameter "project_id" because model "posthog.models.notebook.notebook.Notebook" contained no such field. Consider annotating parameter with @extend_schema. Defaulting to "string".', From f0f2aab058f790ccdf8e217fbec6262ebf7251aa Mon Sep 17 00:00:00 2001 From: JonathanLab Date: Wed, 1 Oct 2025 15:19:33 +0200 Subject: [PATCH 3/3] add some tests --- posthog/api/test/test_integration.py | 196 +++++++++++++++++++++++++++ 1 file changed, 196 insertions(+) diff --git a/posthog/api/test/test_integration.py b/posthog/api/test/test_integration.py index 8ce58f814138..fd801f62fb9f 100644 --- a/posthog/api/test/test_integration.py +++ b/posthog/api/test/test_integration.py @@ -8,6 +8,7 @@ from posthog.api.test.test_team import create_team from posthog.models.integration import PRIVATE_CHANNEL_WITHOUT_ACCESS, EmailIntegration, Integration, SlackIntegration from posthog.models.organization import Organization +from posthog.models.personal_api_key import PersonalAPIKey, hash_key_value from posthog.models.team import Team from posthog.models.user import User @@ -486,3 +487,198 @@ def test_integration_from_config_with_invalid_config( assert response.status_code == status.HTTP_400_BAD_REQUEST assert response.json()["detail"] == expected_error_message + + +class TestIntegrationAPIKeyAccess: + @pytest.fixture(autouse=True) + def setup_integration(self, db): + self.organization = Organization.objects.create(name="Test Org") + self.team = Team.objects.create(organization=self.organization, name="Test Team") + self.user = User.objects.create_and_join(self.organization, "test@posthog.com", "test") + + self.github_integration = Integration.objects.create( + team=self.team, + kind="github", + config={"installation_id": "12345"}, + sensitive_config={"access_token": "test-token"}, + ) + + self.twilio_integration = Integration.objects.create( + team=self.team, + kind="twilio", + config={"account_sid": "test_sid"}, + sensitive_config={"auth_token": "twilio-token"}, + ) + + def test_list_integrations_without_scope_fails(self, client: HttpClient): + key_value = "test_key_123" + PersonalAPIKey.objects.create( + label="Test Key", + user=self.user, + secure_value=hash_key_value(key_value), + scopes=["feature_flag:read"], + ) + + response = client.get( + f"/api/environments/{self.team.pk}/integrations/", + HTTP_AUTHORIZATION=f"Bearer {key_value}", + ) + + assert response.status_code == status.HTTP_403_FORBIDDEN + assert "integration:read" in response.json()["detail"] + + def test_list_integrations_with_scope_succeeds(self, client: HttpClient): + key_value = "test_key_123" + PersonalAPIKey.objects.create( + label="Test Key", + user=self.user, + secure_value=hash_key_value(key_value), + scopes=["integration:read"], + ) + + response = client.get( + f"/api/environments/{self.team.pk}/integrations/", + HTTP_AUTHORIZATION=f"Bearer {key_value}", + ) + + assert response.status_code == status.HTTP_200_OK + assert len(response.json()["results"]) == 1 + assert response.json()["results"][0]["kind"] == "github" + + def test_list_integrations_only_shows_github_for_api_keys(self, client: HttpClient): + key_value = "test_key_123" + PersonalAPIKey.objects.create( + label="Test Key", + user=self.user, + secure_value=hash_key_value(key_value), + scopes=["integration:read"], + ) + + response = client.get( + f"/api/environments/{self.team.pk}/integrations/", + HTTP_AUTHORIZATION=f"Bearer {key_value}", + ) + + assert response.status_code == status.HTTP_200_OK + results = response.json()["results"] + assert len(results) == 1 + assert results[0]["kind"] == "github" + assert all(integration["kind"] == "github" for integration in results) + + def test_retrieve_github_integration_with_scope_succeeds(self, client: HttpClient): + key_value = "test_key_123" + PersonalAPIKey.objects.create( + label="Test Key", + user=self.user, + secure_value=hash_key_value(key_value), + scopes=["integration:read"], + ) + + response = client.get( + f"/api/environments/{self.team.pk}/integrations/{self.github_integration.id}/", + HTTP_AUTHORIZATION=f"Bearer {key_value}", + ) + + assert response.status_code == status.HTTP_200_OK + assert response.json()["kind"] == "github" + + def test_retrieve_non_github_integration_with_api_key_fails(self, client: HttpClient): + key_value = "test_key_123" + PersonalAPIKey.objects.create( + label="Test Key", + user=self.user, + secure_value=hash_key_value(key_value), + scopes=["integration:read"], + ) + + response = client.get( + f"/api/environments/{self.team.pk}/integrations/{self.twilio_integration.id}/", + HTTP_AUTHORIZATION=f"Bearer {key_value}", + ) + + assert response.status_code == status.HTTP_404_NOT_FOUND + + @patch("posthog.models.integration.GitHubIntegration.list_repositories") + def test_github_repos_with_scope_succeeds(self, mock_list_repos, client: HttpClient): + mock_list_repos.return_value = ["repo1", "repo2"] + + key_value = "test_key_123" + PersonalAPIKey.objects.create( + label="Test Key", + user=self.user, + secure_value=hash_key_value(key_value), + scopes=["integration:read"], + ) + + response = client.get( + f"/api/environments/{self.team.pk}/integrations/{self.github_integration.id}/github_repos/", + HTTP_AUTHORIZATION=f"Bearer {key_value}", + ) + + assert response.status_code == status.HTTP_200_OK + assert response.json()["repositories"] == ["repo1", "repo2"] + + def test_github_repos_without_scope_fails(self, client: HttpClient): + key_value = "test_key_123" + PersonalAPIKey.objects.create( + label="Test Key", + user=self.user, + secure_value=hash_key_value(key_value), + scopes=["feature_flag:read"], + ) + + response = client.get( + f"/api/environments/{self.team.pk}/integrations/{self.github_integration.id}/github_repos/", + HTTP_AUTHORIZATION=f"Bearer {key_value}", + ) + + assert response.status_code == status.HTTP_403_FORBIDDEN + assert "integration:read" in response.json()["detail"] + + def test_create_integration_with_api_key_fails(self, client: HttpClient): + key_value = "test_key_123" + PersonalAPIKey.objects.create( + label="Test Key", + user=self.user, + secure_value=hash_key_value(key_value), + scopes=["integration:read"], + ) + + response = client.post( + f"/api/environments/{self.team.pk}/integrations/", + {"kind": "github", "config": {"installation_id": "99999"}}, + content_type="application/json", + HTTP_AUTHORIZATION=f"Bearer {key_value}", + ) + + assert response.status_code == status.HTTP_403_FORBIDDEN + assert "integration:write" in response.json()["detail"] + + def test_delete_integration_with_api_key_fails(self, client: HttpClient): + key_value = "test_key_123" + PersonalAPIKey.objects.create( + label="Test Key", + user=self.user, + secure_value=hash_key_value(key_value), + scopes=["integration:read"], + ) + + response = client.delete( + f"/api/environments/{self.team.pk}/integrations/{self.github_integration.id}/", + HTTP_AUTHORIZATION=f"Bearer {key_value}", + ) + + assert response.status_code == status.HTTP_403_FORBIDDEN + assert "integration:write" in response.json()["detail"] + + def test_session_auth_shows_all_integrations(self, client: HttpClient): + client.force_login(self.user) + + response = client.get(f"/api/environments/{self.team.pk}/integrations/") + + assert response.status_code == status.HTTP_200_OK + results = response.json()["results"] + assert len(results) == 2 + kinds = [integration["kind"] for integration in results] + assert "github" in kinds + assert "twilio" in kinds