From 2619952d91e4fe645176d58f056b70ec53dbb36a Mon Sep 17 00:00:00 2001 From: Vincent Jacques Date: Tue, 16 Jun 2026 15:36:20 +0200 Subject: [PATCH] fix(gitlab): re-push webhook tokens on integration (re)install The GitLab webhook_secret is derived deterministically from the OAuth app's client_id (sha1(hostname + client_id)) and stored on the shared Integration.metadata. When a customer reinstalls against a new OAuth app the client_id changes, so the secret rotates and metadata is overwritten, but the tokens already registered on existing GitLab project hooks are never re-pushed. Inbound webhooks then keep sending the old token and fail the constant-time secret check in webhooks.py with a 409. Override post_install in GitlabIntegrationProvider to schedule a webhook refresh via the existing repository_service.schedule_update_gitlab_project_webhooks machinery. Because the secret lives on the shared Integration, refresh every organization that has the integration installed, not just the one running the (re)install. Fresh installs have no repos yet, so they are a harmless no-op. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/sentry/integrations/gitlab/integration.py | 35 +++++++- .../integrations/gitlab/test_integration.py | 79 +++++++++++++++++++ 2 files changed, 113 insertions(+), 1 deletion(-) diff --git a/src/sentry/integrations/gitlab/integration.py b/src/sentry/integrations/gitlab/integration.py index 99d1221ab9cb91..1bdc0a2bc9cba2 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 c457661199c560..7b4b206515a687 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 + )