Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 34 additions & 1 deletion src/sentry/integrations/gitlab/integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 (
Expand Down Expand Up @@ -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 = """
Expand Down Expand Up @@ -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

Expand Down
79 changes: 79 additions & 0 deletions tests/sentry/integrations/gitlab/test_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Loading