diff --git a/src/sentry/integrations/bitbucket_server/integration.py b/src/sentry/integrations/bitbucket_server/integration.py index 57c9c3c6b28f71..807c0be881c073 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 726bdd044bfff4..8f998c61f7e626 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" + )