From a2aaa5f042467c6f11e86375523093cbb826e386 Mon Sep 17 00:00:00 2001 From: Evan Purkhiser Date: Tue, 26 May 2026 17:32:36 -0400 Subject: [PATCH] feat(bitbucket-server): Add API-driven pipeline backend for Bitbucket Server integration setup Implement `get_pipeline_api_steps()` on `BitbucketServerIntegrationProvider` with two steps: installation config (validates URL, RSA private key, and consumer key length, then fetches an OAuth 1.0a request token from the Bitbucket Server instance), and an OAuth callback step that builds the authorize URL from the request token and exchanges the callback's `oauth_token` (used by Bitbucket Server as the verifier) for an access token. Legacy `InstallationConfigView`, `OAuthLoginView`, and `OAuthCallbackView` remain in place so in-flight installs can complete via the existing flow; they will be removed in a follow-up once the API flow has been validated in production. --- .../bitbucket_server/integration.py | 158 ++++++++++- .../bitbucket_server/test_integration.py | 259 +++++++++++++++++- 2 files changed, 414 insertions(+), 3 deletions(-) diff --git a/src/sentry/integrations/bitbucket_server/integration.py b/src/sentry/integrations/bitbucket_server/integration.py index 57c9c3c6b28f..807c0be881c0 100644 --- a/src/sentry/integrations/bitbucket_server/integration.py +++ b/src/sentry/integrations/bitbucket_server/integration.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import Mapping -from typing import Any +from typing import Any, TypedDict from urllib.parse import parse_qs, quote, urlencode, urlparse from cryptography.hazmat.backends import default_backend @@ -14,7 +14,10 @@ from django.utils.decorators import method_decorator from django.utils.translation import gettext_lazy as _ from django.views.decorators.csrf import csrf_exempt +from rest_framework import serializers +from rest_framework.fields import BooleanField, CharField, URLField +from sentry.api.serializers.rest_framework.base import CamelSnakeSerializer from sentry.integrations.base import ( FeatureDescription, IntegrationData, @@ -40,7 +43,8 @@ ) from sentry.models.repository import Repository from sentry.organizations.services.organization.model import RpcOrganization -from sentry.pipeline.views.base import PipelineView +from sentry.pipeline.types import PipelineStepResult +from sentry.pipeline.views.base import ApiPipelineSteps, PipelineView from sentry.shared_integrations.exceptions import ApiError, IntegrationError from sentry.users.models.identity import Identity from sentry.web.helpers import render_to_response @@ -257,6 +261,153 @@ def dispatch(self, request: HttpRequest, pipeline: IntegrationPipeline) -> HttpR ) +class InstallationConfigData(TypedDict): + url: str + consumer_key: str + private_key: str + verify_ssl: bool + + +class InstallationConfigSerializer(CamelSnakeSerializer[InstallationConfigData]): + url = URLField(required=True) + consumer_key = CharField(required=True, max_length=200) + private_key = CharField(required=True) + verify_ssl = BooleanField(required=False, default=True) + + def validate_private_key(self, value: str) -> str: + try: + load_pem_private_key(value.encode("utf-8"), None, default_backend()) + except Exception: + raise serializers.ValidationError( + "Private key must be a valid SSH private key encoded in a PEM format." + ) + return value + + +class InstallationConfigApiStep: + """ + Collect Bitbucket Server consumer credentials and verify them by fetching an + OAuth 1.0a request token. The token is stored on pipeline state so the next + step can build an authorize URL and exchange it for an access token. + """ + + step_name = "installation_config" + + def get_step_data(self, pipeline: IntegrationPipeline, request: HttpRequest) -> dict[str, Any]: + return {} + + def get_serializer_cls(self) -> type: + return InstallationConfigSerializer + + def handle_post( + self, + validated_data: InstallationConfigData, + pipeline: IntegrationPipeline, + request: HttpRequest, + ) -> PipelineStepResult: + validated_data["url"] = validated_data["url"].rstrip("/") + + client = BitbucketServerSetupClient( + validated_data["url"], + validated_data["consumer_key"], + validated_data["private_key"], + validated_data["verify_ssl"], + ) + + with IntegrationPipelineViewEvent( + IntegrationPipelineViewType.OAUTH_LOGIN, + IntegrationDomain.SOURCE_CODE_MANAGEMENT, + BitbucketServerIntegrationProvider.key, + ).capture() as lifecycle: + try: + request_token = client.get_request_token() + except ApiError as error: + lifecycle.record_failure(str(error), extra={"url": validated_data["url"]}) + return PipelineStepResult.error( + f"Could not fetch a request token from Bitbucket. {error}" + ) + + if not request_token.get("oauth_token") or not request_token.get("oauth_token_secret"): + lifecycle.record_failure( + "missing oauth_token", extra={"url": validated_data["url"]} + ) + return PipelineStepResult.error("Missing oauth_token") + + pipeline.bind_state("installation_data", validated_data) + pipeline.bind_state("request_token", request_token) + return PipelineStepResult.advance() + + +class OAuthCallbackData(TypedDict): + oauth_token: str + + +class OAuthCallbackSerializer(CamelSnakeSerializer[OAuthCallbackData]): + oauth_token = CharField(required=True) + + +class OAuthStepData(TypedDict): + oauthUrl: str + + +class OAuthApiStep: + """ + Build the Bitbucket Server authorize URL from the previously-fetched request + token, then exchange the callback's oauth_token (which Bitbucket Server uses + as the verifier) for an access token. + """ + + step_name = "oauth_callback" + + def _client(self, pipeline: IntegrationPipeline) -> BitbucketServerSetupClient: + installation = pipeline.fetch_state("installation_data") + if installation is None: + raise AssertionError("pipeline called out of order") + return BitbucketServerSetupClient( + installation["url"], + installation["consumer_key"], + installation["private_key"], + installation["verify_ssl"], + ) + + def get_step_data(self, pipeline: IntegrationPipeline, request: HttpRequest) -> OAuthStepData: + request_token = pipeline.fetch_state("request_token") + if request_token is None: + raise AssertionError("pipeline called out of order") + return {"oauthUrl": self._client(pipeline).get_authorize_url(request_token)} + + def get_serializer_cls(self) -> type: + return OAuthCallbackSerializer + + def handle_post( + self, + validated_data: OAuthCallbackData, + pipeline: IntegrationPipeline, + request: HttpRequest, + ) -> PipelineStepResult: + request_token = pipeline.fetch_state("request_token") + if request_token is None: + raise AssertionError("pipeline called out of order") + + with IntegrationPipelineViewEvent( + IntegrationPipelineViewType.OAUTH_CALLBACK, + IntegrationDomain.SOURCE_CODE_MANAGEMENT, + BitbucketServerIntegrationProvider.key, + ).capture() as lifecycle: + try: + access_token = self._client(pipeline).get_access_token( + request_token, validated_data["oauth_token"] + ) + except ApiError as error: + lifecycle.record_failure(str(error)) + return PipelineStepResult.error( + f"Could not fetch an access token from Bitbucket. {error}" + ) + + pipeline.bind_state("access_token", access_token) + return PipelineStepResult.advance() + + class BitbucketServerIntegration(RepositoryIntegration[BitbucketServerClient]): """ IntegrationInstallation implementation for Bitbucket Server @@ -395,6 +546,9 @@ class BitbucketServerIntegrationProvider(IntegrationProvider): def get_pipeline_views(self) -> list[PipelineView[IntegrationPipeline]]: return [InstallationConfigView(), OAuthLoginView(), OAuthCallbackView()] + def get_pipeline_api_steps(self) -> ApiPipelineSteps[IntegrationPipeline]: + return [InstallationConfigApiStep(), OAuthApiStep()] + def post_install( self, integration: Integration, diff --git a/tests/sentry/integrations/bitbucket_server/test_integration.py b/tests/sentry/integrations/bitbucket_server/test_integration.py index 726bdd044bff..8f998c61f7e6 100644 --- a/tests/sentry/integrations/bitbucket_server/test_integration.py +++ b/tests/sentry/integrations/bitbucket_server/test_integration.py @@ -1,17 +1,20 @@ from functools import cached_property +from typing import Any from unittest.mock import MagicMock, patch import responses +from django.urls import reverse from requests.exceptions import ReadTimeout from fixtures.bitbucket_server import EXAMPLE_PRIVATE_KEY from sentry.integrations.bitbucket_server.integration import BitbucketServerIntegrationProvider from sentry.integrations.models.integration import Integration from sentry.integrations.models.organization_integration import OrganizationIntegration +from sentry.integrations.pipeline import IntegrationPipeline from sentry.models.repository import Repository from sentry.silo.base import SiloMode from sentry.testutils.asserts import assert_failure_metric -from sentry.testutils.cases import IntegrationTestCase +from sentry.testutils.cases import APITestCase, IntegrationTestCase from sentry.testutils.silo import assume_test_silo_mode, control_silo_test from sentry.users.models.identity import Identity, IdentityProvider @@ -476,3 +479,257 @@ def test_extract_source_path_from_source_url(self) -> None: ] for source_url, expected in test_cases: assert installation.extract_source_path_from_source_url(repo, source_url) == expected + + +REQUEST_TOKEN_BODY = "oauth_token=req-token&oauth_token_secret=req-token-secret" +ACCESS_TOKEN_BODY = "oauth_token=valid-token&oauth_token_secret=valid-secret" + + +@control_silo_test +class BitbucketServerApiPipelineTest(APITestCase): + endpoint = "sentry-api-0-organization-pipeline" + method = "post" + + bbs_url = "https://bitbucket.example.com" + + def setUp(self) -> None: + super().setUp() + self.login_as(self.user) + + def tearDown(self) -> None: + responses.reset() + super().tearDown() + + def _pipeline_url(self) -> str: + return reverse( + self.endpoint, + args=[self.organization.slug, IntegrationPipeline.pipeline_name], + ) + + def _initialize(self) -> Any: + return self.client.post( + self._pipeline_url(), + data={"action": "initialize", "provider": "bitbucket_server"}, + format="json", + ) + + def _advance(self, data: dict[str, Any]) -> Any: + return self.client.post(self._pipeline_url(), data=data, format="json") + + def _submit_config(self, **overrides: Any) -> Any: + data = { + "url": self.bbs_url, + "consumerKey": "sentry-bot", + "privateKey": EXAMPLE_PRIVATE_KEY, + "verifySsl": False, + } + data.update(overrides) + return self._advance(data) + + def _stub_request_token(self, **kwargs: Any) -> None: + responses.add( + responses.POST, + f"{self.bbs_url}/plugins/servlet/oauth/request-token", + status=kwargs.pop("status", 200), + content_type="text/plain", + body=kwargs.pop("body", REQUEST_TOKEN_BODY), + **kwargs, + ) + + def _stub_access_token(self, **kwargs: Any) -> None: + responses.add( + responses.POST, + f"{self.bbs_url}/plugins/servlet/oauth/access-token", + status=kwargs.pop("status", 200), + content_type="text/plain", + body=kwargs.pop("body", ACCESS_TOKEN_BODY), + **kwargs, + ) + + @responses.activate + def test_initialize_pipeline(self) -> None: + resp = self._initialize() + assert resp.status_code == 200 + assert resp.data["provider"] == "bitbucket_server" + assert resp.data["step"] == "installation_config" + assert resp.data["stepIndex"] == 0 + assert resp.data["totalSteps"] == 2 + assert resp.data["data"] == {} + + @responses.activate + def test_config_step_validation_missing_required_fields(self) -> None: + self._initialize() + resp = self._advance({"url": self.bbs_url}) + assert resp.status_code == 400 + for field in ("consumerKey", "privateKey"): + assert resp.data[field] == ["This field is required."] + + @responses.activate + def test_config_step_validation_invalid_url(self) -> None: + self._initialize() + resp = self._submit_config(url="bitbucket.example.com") + assert resp.status_code == 400 + assert resp.data["url"] == ["Enter a valid URL."] + + @responses.activate + def test_config_step_validation_invalid_private_key(self) -> None: + self._initialize() + resp = self._submit_config(privateKey="hot-garbage") + assert resp.status_code == 400 + assert "PEM format" in resp.data["privateKey"][0] + + @responses.activate + def test_config_step_validation_consumer_key_too_long(self) -> None: + self._initialize() + resp = self._submit_config(consumerKey="x" * 201) + assert resp.status_code == 400 + assert "200 characters" in resp.data["consumerKey"][0] + + @responses.activate + def test_config_step_advance(self) -> None: + self._stub_request_token() + self._initialize() + resp = self._submit_config() + assert resp.status_code == 200 + assert resp.data["status"] == "advance" + assert resp.data["step"] == "oauth_callback" + assert resp.data["stepIndex"] == 1 + assert resp.data["data"]["oauthUrl"] == ( + f"{self.bbs_url}/plugins/servlet/oauth/authorize?oauth_token=req-token" + ) + + @responses.activate + def test_config_step_strips_trailing_slash(self) -> None: + self._stub_request_token() + self._initialize() + resp = self._submit_config(url=f"{self.bbs_url}//") + assert resp.status_code == 200 + assert resp.data["status"] == "advance" + assert resp.data["data"]["oauthUrl"] == ( + f"{self.bbs_url}/plugins/servlet/oauth/authorize?oauth_token=req-token" + ) + + @responses.activate + @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") + def test_config_step_request_token_timeout(self, mock_record: MagicMock) -> None: + responses.add( + responses.POST, + f"{self.bbs_url}/plugins/servlet/oauth/request-token", + body=ReadTimeout("Read timed out. (read timeout=30)"), + ) + self._initialize() + resp = self._submit_config() + assert resp.status_code == 400 + assert resp.data["status"] == "error" + assert "request token from Bitbucket" in resp.data["data"]["detail"] + assert_failure_metric( + mock_record, "Timed out attempting to reach host: bitbucket.example.com" + ) + + @responses.activate + @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") + def test_config_step_request_token_fails(self, mock_record: MagicMock) -> None: + self._stub_request_token(status=503, body="") + self._initialize() + resp = self._submit_config() + assert resp.status_code == 400 + assert resp.data["status"] == "error" + assert "request token from Bitbucket" in resp.data["data"]["detail"] + assert_failure_metric(mock_record, "") + + @responses.activate + def test_oauth_step_validation_missing_token(self) -> None: + self._stub_request_token() + self._initialize() + self._submit_config() + resp = self._advance({}) + assert resp.status_code == 400 + assert resp.data["oauthToken"] == ["This field is required."] + + @responses.activate + def test_oauth_step_passes_callback_token_as_verifier(self) -> None: + # Bitbucket Server uses the callback's oauth_token as the OAuth 1.0a + # verifier when exchanging for an access token. Confirm the access-token + # request signature contains the value from the callback. + self._stub_request_token() + self._stub_access_token() + self._initialize() + self._submit_config() + self._advance({"oauthToken": "callback-token"}) + + access_token_calls = [ + call + for call in responses.calls + if call.request.url == f"{self.bbs_url}/plugins/servlet/oauth/access-token" + ] + assert len(access_token_calls) == 1 + assert ( + 'oauth_verifier="callback-token"' + in access_token_calls[0].request.headers["Authorization"] + ) + + @responses.activate + @patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") + def test_oauth_step_access_token_failure(self, mock_record: MagicMock) -> None: + self._stub_request_token() + error_msg = "it broke" + self._stub_access_token(status=500, body=error_msg) + self._initialize() + self._submit_config() + + resp = self._advance({"oauthToken": "callback-token"}) + assert resp.status_code == 400 + assert resp.data["status"] == "error" + assert "access token from Bitbucket" in resp.data["data"]["detail"] + assert_failure_metric(mock_record, error_msg) + + @responses.activate + def test_full_pipeline_flow(self) -> None: + self._stub_request_token() + self._stub_access_token() + + resp = self._initialize() + assert resp.data["step"] == "installation_config" + + resp = self._submit_config() + assert resp.data["step"] == "oauth_callback" + + resp = self._advance({"oauthToken": "callback-token"}) + assert resp.status_code == 200 + assert resp.data["status"] == "complete" + + integration = Integration.objects.get(provider="bitbucket_server") + assert integration.name == "sentry-bot" + assert integration.external_id == "bitbucket.example.com:sentry-bot" + assert integration.metadata["base_url"] == self.bbs_url + assert integration.metadata["domain_name"] == "bitbucket.example.com" + assert integration.metadata["verify_ssl"] is False + + assert OrganizationIntegration.objects.filter( + integration=integration, organization_id=self.organization.id + ).exists() + + idp = IdentityProvider.objects.get(type="bitbucket_server") + identity = Identity.objects.get( + idp=idp, user=self.user, external_id="bitbucket.example.com:sentry-bot" + ) + assert identity.data["consumer_key"] == "sentry-bot" + assert identity.data["access_token"] == "valid-token" + assert identity.data["access_token_secret"] == "valid-secret" + assert identity.data["private_key"] == EXAMPLE_PRIVATE_KEY + + @responses.activate + def test_full_pipeline_truncates_external_id(self) -> None: + self._stub_request_token() + self._stub_access_token() + + self._initialize() + long_key = "a-very-long-consumer-key-that-when-combined-with-host-would-overflow" + self._submit_config(consumerKey=long_key) + self._advance({"oauthToken": "callback-token"}) + + integration = Integration.objects.get(provider="bitbucket_server") + assert ( + integration.external_id + == "bitbucket.example.com:a-very-long-consumer-key-that-when-combine" + )