Browse Source

feat(hybrid-cloud): Use IntegrationProxyClient for Bitbucket Server (#51760)

This PR introduces the proxy client for Bitbucket Server and has
associated tests. It functions almost exactly like Jira Server.
Leander Rodrigues 1 year ago
parent
commit
6c2303c3fe

+ 36 - 22
src/sentry/integrations/bitbucket_server/client.py

@@ -2,9 +2,14 @@ import logging
 from urllib.parse import parse_qsl
 
 from oauthlib.oauth1 import SIGNATURE_RSA
+from requests import PreparedRequest
 from requests_oauthlib import OAuth1
 
 from sentry.integrations.client import ApiClient
+from sentry.models.identity import Identity
+from sentry.services.hybrid_cloud.integration.model import RpcIntegration
+from sentry.services.hybrid_cloud.util import control_silo_function
+from sentry.shared_integrations.client.proxy import IntegrationProxyClient
 from sentry.shared_integrations.exceptions import ApiError
 
 logger = logging.getLogger("sentry.integrations.bitbucket_server")
@@ -91,7 +96,7 @@ class BitbucketServerSetupClient(ApiClient):
         return self._request(*args, **kwargs)
 
 
-class BitbucketServer(ApiClient):
+class BitbucketServerClient(IntegrationProxyClient):
     """
     Contains the BitBucket Server specifics in order to communicate with bitbucket
 
@@ -102,36 +107,57 @@ class BitbucketServer(ApiClient):
 
     integration_name = "bitbucket_server"
 
-    def __init__(self, base_url, credentials, verify_ssl):
-        super().__init__(verify_ssl)
+    def __init__(
+        self,
+        integration: RpcIntegration,
+        identity_id: int,
+        org_integration_id: int,
+    ):
+        self.base_url = integration.metadata["base_url"]
+        self.identity_id = identity_id
+        super().__init__(
+            org_integration_id=org_integration_id,
+            verify_ssl=integration.metadata["verify_ssl"],
+            logging_context=None,
+        )
 
-        self.base_url = base_url
-        self.credentials = credentials
+    @control_silo_function
+    def authorize_request(self, prepared_request: PreparedRequest):
+        """Bitbucket Server authorizes with RSA-signed OAuth1 scheme"""
+        identity = Identity.objects.filter(id=self.identity_id).first()
+        if not identity:
+            return prepared_request
+        auth_scheme = OAuth1(
+            client_key=identity.data["consumer_key"],
+            rsa_key=identity.data["private_key"],
+            resource_owner_key=identity.data["access_token"],
+            resource_owner_secret=identity.data["access_token_secret"],
+            signature_method=SIGNATURE_RSA,
+            signature_type="auth_header",
+        )
+        prepared_request.prepare_auth(auth=auth_scheme)
+        return prepared_request
 
     def get_repos(self):
         return self.get(
             BitbucketServerAPIPath.repositories,
-            auth=self.get_auth(),
             params={"limit": 250, "permission": "REPO_ADMIN"},
         )
 
     def search_repositories(self, query_string):
         return self.get(
             BitbucketServerAPIPath.repositories,
-            auth=self.get_auth(),
             params={"limit": 250, "permission": "REPO_ADMIN", "name": query_string},
         )
 
     def get_repo(self, project, repo):
         return self.get(
             BitbucketServerAPIPath.repository.format(project=project, repo=repo),
-            auth=self.get_auth(),
         )
 
     def create_hook(self, project, repo, data):
         return self.post(
             BitbucketServerAPIPath.repository_hooks.format(project=project, repo=repo),
-            auth=self.get_auth(),
             data=data,
         )
 
@@ -140,7 +166,6 @@ class BitbucketServer(ApiClient):
             BitbucketServerAPIPath.repository_hook.format(
                 project=project, repo=repo, id=webhook_id
             ),
-            auth=self.get_auth(),
         )
 
     def get_commits(self, project, repo, from_hash, to_hash, limit=1000):
@@ -162,7 +187,6 @@ class BitbucketServer(ApiClient):
     def get_last_commits(self, project, repo, limit=10):
         return self.get(
             BitbucketServerAPIPath.repository_commits.format(project=project, repo=repo),
-            auth=self.get_auth(),
             params={"merges": "exclude", "limit": limit},
         )["values"]
 
@@ -181,16 +205,6 @@ class BitbucketServer(ApiClient):
             {"limit": limit},
         )
 
-    def get_auth(self):
-        return OAuth1(
-            client_key=self.credentials["consumer_key"],
-            rsa_key=self.credentials["private_key"],
-            resource_owner_key=self.credentials["access_token"],
-            resource_owner_secret=self.credentials["access_token_secret"],
-            signature_method=SIGNATURE_RSA,
-            signature_type="auth_header",
-        )
-
     def _get_values(self, uri, params, max_pages=1000000):
         values = []
         start = 0
@@ -211,7 +225,7 @@ class BitbucketServer(ApiClient):
                 f"Loading values for paginated uri starting from {start}",
                 extra={"uri": uri, "params": new_params},
             )
-            data = self.get(uri, auth=self.get_auth(), params=new_params)
+            data = self.get(uri, params=new_params)
             logger.debug(
                 f'{len(data["values"])} values loaded', extra={"uri": uri, "params": new_params}
             )

+ 5 - 5
src/sentry/integrations/bitbucket_server/integration.py

@@ -29,7 +29,7 @@ from sentry.shared_integrations.exceptions import ApiError, IntegrationError
 from sentry.tasks.integrations import migrate_repo
 from sentry.web.helpers import render_to_response
 
-from .client import BitbucketServer, BitbucketServerSetupClient
+from .client import BitbucketServerClient, BitbucketServerSetupClient
 from .repository import BitbucketServerRepositoryProvider
 
 logger = logging.getLogger("sentry.integrations.bitbucket_server")
@@ -238,10 +238,10 @@ class BitbucketServerIntegration(IntegrationInstallation, RepositoryMixin):
             except Identity.DoesNotExist:
                 raise IntegrationError("Identity not found.")
 
-        return BitbucketServer(
-            self.model.metadata["base_url"],
-            self.default_identity.data,
-            self.model.metadata["verify_ssl"],
+        return BitbucketServerClient(
+            integration=self.model,
+            identity_id=self.org_integration.default_auth_id,
+            org_integration_id=self.org_integration.id,
         )
 
     @property

+ 133 - 0
tests/sentry/integrations/bitbucket_server/test_client.py

@@ -0,0 +1,133 @@
+import re
+
+import responses
+from django.test import override_settings
+from requests import Request
+
+from sentry.integrations.bitbucket_server.client import (
+    BitbucketServerAPIPath,
+    BitbucketServerClient,
+)
+from sentry.models import Integration
+from sentry.silo.base import SiloMode
+from sentry.silo.util import PROXY_BASE_PATH, PROXY_OI_HEADER, PROXY_SIGNATURE_HEADER
+from sentry.testutils import TestCase
+from sentry.testutils.cases import BaseTestCase
+from sentry.testutils.silo import control_silo_test
+from tests.sentry.integrations.jira_server import EXAMPLE_PRIVATE_KEY
+
+control_address = "http://controlserver"
+secret = "hush-hush-im-invisible"
+
+
+@override_settings(
+    SENTRY_SUBNET_SECRET=secret,
+    SENTRY_CONTROL_ADDRESS=control_address,
+)
+@control_silo_test(stable=True)
+class BitbucketServerClientTest(TestCase, BaseTestCase):
+    def setUp(self):
+        self.integration = Integration.objects.create(
+            provider="bitbucket_server",
+            name="Bitbucket Server",
+            metadata={"base_url": "https://bitbucket.example.com", "verify_ssl": True},
+        )
+
+        idp = self.create_identity_provider(integration=self.integration)
+        self.identity = self.create_identity(
+            user=self.user,
+            identity_provider=idp,
+            external_id="bitbucket:123",
+            data={
+                "consumer_key": "cnsmr-key",
+                "private_key": EXAMPLE_PRIVATE_KEY,
+                "access_token": "acs-tkn",
+                "access_token_secret": "acs-tkn-scrt",
+            },
+        )
+        self.integration.add_organization(
+            self.organization, self.user, default_auth_id=self.identity.id
+        )
+        self.install = self.integration.get_installation(self.organization.id)
+        self.bb_server_client: BitbucketServerClient = self.install.get_client()
+
+    def test_authorize_request(self):
+        method = "GET"
+        request = Request(
+            method=method,
+            url=f"{self.bb_server_client.base_url}{BitbucketServerAPIPath.repositories}",
+        ).prepare()
+
+        self.bb_server_client.authorize_request(prepared_request=request)
+        consumer_key = self.identity.data["consumer_key"]
+        access_token = self.identity.data["access_token"]
+        header_components = [
+            'oauth_signature_method="RSA-SHA1"',
+            f'oauth_consumer_key="{consumer_key}"',
+            f'oauth_token="{access_token}"',
+            "oauth_signature",
+        ]
+        for hc in header_components:
+            assert hc in str(request.headers["Authorization"])
+
+    @responses.activate
+    def test_integration_proxy_is_active(self):
+        class BitbucketServerProxyTestClient(BitbucketServerClient):
+            _use_proxy_url_for_tests = True
+
+            def assert_proxy_request(self, request, is_proxy=True):
+                assert (PROXY_BASE_PATH in request.url) == is_proxy
+                assert (PROXY_OI_HEADER in request.headers) == is_proxy
+                assert (PROXY_SIGNATURE_HEADER in request.headers) == is_proxy
+                assert ("Authorization" in request.headers) != is_proxy
+                if is_proxy:
+                    assert request.headers[PROXY_OI_HEADER] is not None
+
+        responses.add(
+            method=responses.GET,
+            # Use regex to create responses both from proxy and integration
+            url=re.compile(rf"\S+{BitbucketServerAPIPath.repositories}"),
+            json={"ok": True},
+            status=200,
+        )
+
+        with override_settings(SILO_MODE=SiloMode.MONOLITH):
+            client = BitbucketServerProxyTestClient(
+                integration=self.integration,
+                identity_id=self.identity.id,
+                org_integration_id=self.install.org_integration.id,
+            )
+            client.get_repos()
+            request = responses.calls[0].request
+
+            assert BitbucketServerAPIPath.repositories in request.url
+            assert client.base_url in request.url
+            client.assert_proxy_request(request, is_proxy=False)
+
+        responses.calls.reset()
+        with override_settings(SILO_MODE=SiloMode.CONTROL):
+            client = BitbucketServerProxyTestClient(
+                integration=self.integration,
+                identity_id=self.identity.id,
+                org_integration_id=self.install.org_integration.id,
+            )
+            client.get_repos()
+            request = responses.calls[0].request
+
+            assert BitbucketServerAPIPath.repositories in request.url
+            assert client.base_url in request.url
+            client.assert_proxy_request(request, is_proxy=False)
+
+        responses.calls.reset()
+        with override_settings(SILO_MODE=SiloMode.REGION):
+            client = BitbucketServerProxyTestClient(
+                integration=self.integration,
+                identity_id=self.identity.id,
+                org_integration_id=self.install.org_integration.id,
+            )
+            client.get_repos()
+            request = responses.calls[0].request
+
+            assert BitbucketServerAPIPath.repositories in request.url
+            assert client.base_url not in request.url
+            client.assert_proxy_request(request, is_proxy=True)