diff --git a/src/sentry/integrations/gitlab/integration.py b/src/sentry/integrations/gitlab/integration.py index 99d1221ab9cb..1bdc0a2bc9cb 100644 --- a/src/sentry/integrations/gitlab/integration.py +++ b/src/sentry/integrations/gitlab/integration.py @@ -2,7 +2,7 @@ import logging from collections.abc import Mapping, MutableMapping -from typing import Any +from typing import TYPE_CHECKING, Any from urllib.parse import urlparse from django.db import router, transaction @@ -13,6 +13,7 @@ from sentry import features from sentry.api.serializers.rest_framework.base import CamelSnakeSerializer +from sentry.constants import ObjectStatus from sentry.identity.gitlab.provider import GitlabIdentityProvider, get_oauth_data, get_user_info from sentry.identity.oauth2 import OAuth2ApiStep from sentry.integrations.base import ( @@ -67,6 +68,10 @@ from .repository import GitlabRepositoryProvider from .utils import parse_gitlab_blob_url +if TYPE_CHECKING: + from sentry.integrations.models.integration import Integration + from sentry.organizations.services.organization.model import RpcOrganization + logger = logging.getLogger("sentry.integrations.gitlab") DESCRIPTION = """ @@ -747,6 +752,34 @@ def build_integration(self, state: Mapping[str, Any]) -> IntegrationData: }, } + def post_install( + self, + integration: Integration, + organization: RpcOrganization, + *, + extra: dict[str, Any], + ) -> None: + # Re-push webhook tokens so existing project hooks pick up the current + # metadata["webhook_secret"]. On a reinstall against a new OAuth app the + # client_id (and therefore the secret) changes; without this, inbound + # webhooks keep sending the old token and fail the secret check in + # webhooks.py. + # + # The webhook_secret lives on the shared Integration.metadata, so a + # rotation affects every organization that has this integration + # installed -- not just the one running the (re)install. Refresh them + # all. On a fresh install the installing org has no repos yet, so its + # refresh is a harmless no-op. + org_integrations = integration_service.get_organization_integrations( + integration_id=integration.id, + status=ObjectStatus.ACTIVE, + ) + for org_integration in org_integrations: + repository_service.schedule_update_gitlab_project_webhooks( + organization_id=org_integration.organization_id, + integration_id=integration.id, + ) + def setup(self): from sentry.plugins.base import bindings diff --git a/tests/sentry/integrations/gitlab/test_integration.py b/tests/sentry/integrations/gitlab/test_integration.py index c457661199c5..7b4b206515a6 100644 --- a/tests/sentry/integrations/gitlab/test_integration.py +++ b/tests/sentry/integrations/gitlab/test_integration.py @@ -1392,3 +1392,82 @@ def test_config_strips_trailing_slash(self) -> None: oauth_url = resp.data["data"]["oauthUrl"] assert "gitlab.example.com/oauth/authorize" in oauth_url assert "///" not in oauth_url + + def _complete_pipeline(self, **config_overrides: Any) -> None: + self._stub_gitlab_oauth() + self._stub_gitlab_user() + self._stub_gitlab_group() + + self._initialize_pipeline() + resp = self._submit_config(**config_overrides) + pipeline_signature = self._get_pipeline_signature(resp) + resp = self._advance_step({"code": "gitlab-auth-code", "state": pipeline_signature}) + assert resp.status_code == 200 + assert resp.data["status"] == "complete" + + @responses.activate + def test_install_schedules_webhook_refresh(self) -> None: + with patch( + "sentry.integrations.gitlab.integration.repository_service.schedule_update_gitlab_project_webhooks" + ) as mock_schedule: + self._complete_pipeline() + + integration = Integration.objects.get(provider="gitlab") + mock_schedule.assert_called_once_with( + organization_id=self.organization.id, + integration_id=integration.id, + ) + + @responses.activate + def test_reinstall_with_new_client_id_rotates_secret_and_reschedules(self) -> None: + # First install. + self._complete_pipeline() + integration = Integration.objects.get(provider="gitlab") + original_secret = integration.metadata["webhook_secret"] + + responses.reset() + + # Reinstall against a different OAuth app (new client_id). The external_id + # is unchanged, so this upserts the same integration but rotates the secret. + self.client_id = "app-id-different-456" + with patch( + "sentry.integrations.gitlab.integration.repository_service.schedule_update_gitlab_project_webhooks" + ) as mock_schedule: + self._complete_pipeline() + + integration.refresh_from_db() + assert integration.metadata["webhook_secret"] != original_secret + mock_schedule.assert_called_once_with( + organization_id=self.organization.id, + integration_id=integration.id, + ) + + @responses.activate + def test_reinstall_refreshes_all_shared_orgs(self) -> None: + # First org installs. + self._complete_pipeline() + integration = Integration.objects.get(provider="gitlab") + + # A second Sentry org shares the same integration (same hostname:group_id, + # so the install pipeline upserts the same Integration row). + other_org = self.create_organization() + integration.add_organization(other_org) + + responses.reset() + + # Reinstall against a different OAuth app rotates the shared secret. + self.client_id = "app-id-different-456" + with patch( + "sentry.integrations.gitlab.integration.repository_service.schedule_update_gitlab_project_webhooks" + ) as mock_schedule: + self._complete_pipeline() + + # Both orgs sharing the integration get a refresh scheduled, not just the + # org that ran the reinstall. + scheduled_org_ids = { + call.kwargs["organization_id"] for call in mock_schedule.call_args_list + } + assert scheduled_org_ids == {self.organization.id, other_org.id} + assert all( + call.kwargs["integration_id"] == integration.id for call in mock_schedule.call_args_list + )