From 43be90f05176d62e5a3af81c64d44770689f7f45 Mon Sep 17 00:00:00 2001 From: Patrick Uiterwijk Date: Fri, 5 Jan 2024 15:34:29 +0100 Subject: [PATCH] Add support for Bitbucket Data Center webhooks (#14674) Add support for receiving webhooks from Bitbucket Data Center, and add support for posting build statuses back Note that this is very explicitly only for Bitbucket Data Center. The entire webhook format and API is entirely different for Bitbucket Cloud. --- awx/api/urls/webhooks.py | 3 +- awx/api/views/webhooks.py | 97 +++++++++++++++++-- .../0188_add_bitbucket_dc_webhook.py | 52 ++++++++++ awx/main/models/credential/__init__.py | 19 ++++ awx/main/models/mixins.py | 27 +++++- awx/main/tests/functional/test_credential.py | 1 + .../screens/Template/shared/WebhookSubForm.js | 6 ++ 7 files changed, 190 insertions(+), 15 deletions(-) create mode 100644 awx/main/migrations/0188_add_bitbucket_dc_webhook.py diff --git a/awx/api/urls/webhooks.py b/awx/api/urls/webhooks.py index b57ca135d8c2..bbbf1ebd2d5c 100644 --- a/awx/api/urls/webhooks.py +++ b/awx/api/urls/webhooks.py @@ -1,10 +1,11 @@ from django.urls import re_path -from awx.api.views.webhooks import WebhookKeyView, GithubWebhookReceiver, GitlabWebhookReceiver +from awx.api.views.webhooks import WebhookKeyView, GithubWebhookReceiver, GitlabWebhookReceiver, BitbucketDcWebhookReceiver urlpatterns = [ re_path(r'^webhook_key/$', WebhookKeyView.as_view(), name='webhook_key'), re_path(r'^github/$', GithubWebhookReceiver.as_view(), name='webhook_receiver_github'), re_path(r'^gitlab/$', GitlabWebhookReceiver.as_view(), name='webhook_receiver_gitlab'), + re_path(r'^bitbucket_dc/$', BitbucketDcWebhookReceiver.as_view(), name='webhook_receiver_bitbucket_dc'), ] diff --git a/awx/api/views/webhooks.py b/awx/api/views/webhooks.py index a1d3e272032c..c0fa81380e96 100644 --- a/awx/api/views/webhooks.py +++ b/awx/api/views/webhooks.py @@ -1,4 +1,4 @@ -from hashlib import sha1 +from hashlib import sha1, sha256 import hmac import logging import urllib.parse @@ -99,14 +99,31 @@ def get_event_ref(self): def get_signature(self): raise NotImplementedError + def must_check_signature(self): + return True + + def is_ignored_request(self): + return False + def check_signature(self, obj): if not obj.webhook_key: raise PermissionDenied + if not self.must_check_signature(): + logger.debug("skipping signature validation") + return + + hash_alg, expected_digest = self.get_signature() + if hash_alg == 'sha1': + mac = hmac.new(force_bytes(obj.webhook_key), msg=force_bytes(self.request.body), digestmod=sha1) + elif hash_alg == 'sha256': + mac = hmac.new(force_bytes(obj.webhook_key), msg=force_bytes(self.request.body), digestmod=sha256) + else: + logger.debug("Unsupported signature type, supported: sha1, sha256, received: {}".format(hash_alg)) + raise PermissionDenied - mac = hmac.new(force_bytes(obj.webhook_key), msg=force_bytes(self.request.body), digestmod=sha1) - logger.debug("header signature: %s", self.get_signature()) + logger.debug("header signature: %s", expected_digest) logger.debug("calculated signature: %s", force_bytes(mac.hexdigest())) - if not hmac.compare_digest(force_bytes(mac.hexdigest()), self.get_signature()): + if not hmac.compare_digest(force_bytes(mac.hexdigest()), expected_digest): raise PermissionDenied @csrf_exempt @@ -118,6 +135,10 @@ def post(self, request, *args, **kwargs): obj = self.get_object() self.check_signature(obj) + if self.is_ignored_request(): + # This was an ignored request type (e.g. ping), don't act on it + return Response({'message': _("Webhook ignored")}, status=status.HTTP_200_OK) + event_type = self.get_event_type() event_guid = self.get_event_guid() event_ref = self.get_event_ref() @@ -186,7 +207,7 @@ def get_signature(self): if hash_alg != 'sha1': logger.debug("Unsupported signature type, expected: sha1, received: {}".format(hash_alg)) raise PermissionDenied - return force_bytes(signature) + return hash_alg, force_bytes(signature) class GitlabWebhookReceiver(WebhookReceiverBase): @@ -214,15 +235,73 @@ def get_event_status_api(self): return "{}://{}/api/v4/projects/{}/statuses/{}".format(parsed.scheme, parsed.netloc, project['id'], self.get_event_ref()) - def get_signature(self): - return force_bytes(self.request.META.get('HTTP_X_GITLAB_TOKEN') or '') - def check_signature(self, obj): if not obj.webhook_key: raise PermissionDenied + token_from_request = force_bytes(self.request.META.get('HTTP_X_GITLAB_TOKEN') or '') + # GitLab only returns the secret token, not an hmac hash. Use # the hmac `compare_digest` helper function to prevent timing # analysis by attackers. - if not hmac.compare_digest(force_bytes(obj.webhook_key), self.get_signature()): + if not hmac.compare_digest(force_bytes(obj.webhook_key), token_from_request): raise PermissionDenied + + +class BitbucketDcWebhookReceiver(WebhookReceiverBase): + service = 'bitbucket_dc' + + ref_keys = { + 'repo:refs_changed': 'changes.0.toHash', + 'mirror:repo_synchronized': 'changes.0.toHash', + 'pr:opened': 'pullRequest.toRef.latestCommit', + 'pr:from_ref_updated': 'pullRequest.toRef.latestCommit', + 'pr:modified': 'pullRequest.toRef.latestCommit', + } + + def get_event_type(self): + return self.request.META.get('HTTP_X_EVENT_KEY') + + def get_event_guid(self): + return self.request.META.get('HTTP_X_REQUEST_ID') + + def get_event_status_api(self): + # https:///rest/build-status/1.0/commits/ + if self.get_event_type() not in self.ref_keys.keys(): + return + if self.get_event_ref() is None: + return + any_url = None + if 'actor' in self.request.data: + any_url = self.request.data['actor'].get('links', {}).get('self') + if any_url is None and 'repository' in self.request.data: + any_url = self.request.data['repository'].get('links', {}).get('self') + if any_url is None: + return + any_url = any_url[0].get('href') + if any_url is None: + return + parsed = urllib.parse.urlparse(any_url) + + return "{}://{}/rest/build-status/1.0/commits/{}".format(parsed.scheme, parsed.netloc, self.get_event_ref()) + + def is_ignored_request(self): + return self.get_event_type() not in [ + 'repo:refs_changed', + 'mirror:repo_synchronized', + 'pr:opened', + 'pr:from_ref_updated', + 'pr:modified', + ] + + def must_check_signature(self): + # Bitbucket does not sign ping requests... + return self.get_event_type() != 'diagnostics:ping' + + def get_signature(self): + header_sig = self.request.META.get('HTTP_X_HUB_SIGNATURE') + if not header_sig: + logger.debug("Expected signature missing from header key HTTP_X_HUB_SIGNATURE") + raise PermissionDenied + hash_alg, signature = header_sig.split('=') + return hash_alg, force_bytes(signature) diff --git a/awx/main/migrations/0188_add_bitbucket_dc_webhook.py b/awx/main/migrations/0188_add_bitbucket_dc_webhook.py new file mode 100644 index 000000000000..ae067b2cbe86 --- /dev/null +++ b/awx/main/migrations/0188_add_bitbucket_dc_webhook.py @@ -0,0 +1,52 @@ +# Generated by Django 4.2.6 on 2023-11-16 21:00 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ('main', '0187_hop_nodes'), + ] + + operations = [ + migrations.AlterField( + model_name='job', + name='webhook_service', + field=models.CharField( + blank=True, + choices=[('github', 'GitHub'), ('gitlab', 'GitLab'), ('bitbucket_dc', 'BitBucket DataCenter')], + help_text='Service that webhook requests will be accepted from', + max_length=16, + ), + ), + migrations.AlterField( + model_name='jobtemplate', + name='webhook_service', + field=models.CharField( + blank=True, + choices=[('github', 'GitHub'), ('gitlab', 'GitLab'), ('bitbucket_dc', 'BitBucket DataCenter')], + help_text='Service that webhook requests will be accepted from', + max_length=16, + ), + ), + migrations.AlterField( + model_name='workflowjob', + name='webhook_service', + field=models.CharField( + blank=True, + choices=[('github', 'GitHub'), ('gitlab', 'GitLab'), ('bitbucket_dc', 'BitBucket DataCenter')], + help_text='Service that webhook requests will be accepted from', + max_length=16, + ), + ), + migrations.AlterField( + model_name='workflowjobtemplate', + name='webhook_service', + field=models.CharField( + blank=True, + choices=[('github', 'GitHub'), ('gitlab', 'GitLab'), ('bitbucket_dc', 'BitBucket DataCenter')], + help_text='Service that webhook requests will be accepted from', + max_length=16, + ), + ), + ] diff --git a/awx/main/models/credential/__init__.py b/awx/main/models/credential/__init__.py index 5de77ff62d8a..c731001f4274 100644 --- a/awx/main/models/credential/__init__.py +++ b/awx/main/models/credential/__init__.py @@ -953,6 +953,25 @@ def create(self): }, ) +ManagedCredentialType( + namespace='bitbucket_dc_token', + kind='token', + name=gettext_noop('Bitbucket Data Center HTTP Access Token'), + managed=True, + inputs={ + 'fields': [ + { + 'id': 'token', + 'label': gettext_noop('Token'), + 'type': 'string', + 'secret': True, + 'help_text': gettext_noop('This token needs to come from your user settings in Bitbucket'), + } + ], + 'required': ['token'], + }, +) + ManagedCredentialType( namespace='insights', kind='insights', diff --git a/awx/main/models/mixins.py b/awx/main/models/mixins.py index fd92b0b5c367..a2b787396777 100644 --- a/awx/main/models/mixins.py +++ b/awx/main/models/mixins.py @@ -562,6 +562,7 @@ class Meta: SERVICES = [ ('github', "GitHub"), ('gitlab', "GitLab"), + ('bitbucket_dc', "BitBucket DataCenter"), ] webhook_service = models.CharField(max_length=16, choices=SERVICES, blank=True, help_text=_('Service that webhook requests will be accepted from')) @@ -622,6 +623,7 @@ def update_webhook_status(self, status): service_header = { 'github': ('Authorization', 'token {}'), 'gitlab': ('PRIVATE-TOKEN', '{}'), + 'bitbucket_dc': ('Authorization', 'Bearer {}'), } service_statuses = { 'github': { @@ -639,6 +641,14 @@ def update_webhook_status(self, status): 'error': 'failed', # GitLab doesn't have an 'error' status distinct from 'failed' :( 'canceled': 'canceled', }, + 'bitbucket_dc': { + 'pending': 'INPROGRESS', # Bitbucket DC doesn't have any other statuses distinct from INPROGRESS, SUCCESSFUL, FAILED :( + 'running': 'INPROGRESS', + 'successful': 'SUCCESSFUL', + 'failed': 'FAILED', + 'error': 'FAILED', + 'canceled': 'FAILED', + }, } statuses = service_statuses[self.webhook_service] @@ -647,11 +657,18 @@ def update_webhook_status(self, status): return try: license_type = get_licenser().validate().get('license_type') - data = { - 'state': statuses[status], - 'context': 'ansible/awx' if license_type == 'open' else 'ansible/tower', - 'target_url': self.get_ui_url(), - } + if self.webhook_service == 'bitbucket_dc': + data = { + 'state': statuses[status], + 'key': 'ansible/awx' if license_type == 'open' else 'ansible/tower', + 'url': self.get_ui_url(), + } + else: + data = { + 'state': statuses[status], + 'context': 'ansible/awx' if license_type == 'open' else 'ansible/tower', + 'target_url': self.get_ui_url(), + } k, v = service_header[self.webhook_service] headers = {k: v.format(self.webhook_credential.get_input('token')), 'Content-Type': 'application/json'} response = requests.post(status_api, data=json.dumps(data), headers=headers, timeout=30) diff --git a/awx/main/tests/functional/test_credential.py b/awx/main/tests/functional/test_credential.py index d61f2e09ba53..c018e735bf63 100644 --- a/awx/main/tests/functional/test_credential.py +++ b/awx/main/tests/functional/test_credential.py @@ -81,6 +81,7 @@ def test_default_cred_types(): 'aws_secretsmanager_credential', 'azure_kv', 'azure_rm', + 'bitbucket_dc_token', 'centrify_vault_kv', 'conjur', 'controller', diff --git a/awx/ui/src/screens/Template/shared/WebhookSubForm.js b/awx/ui/src/screens/Template/shared/WebhookSubForm.js index ed5cf7a825c9..0f64ffde65c8 100644 --- a/awx/ui/src/screens/Template/shared/WebhookSubForm.js +++ b/awx/ui/src/screens/Template/shared/WebhookSubForm.js @@ -112,6 +112,12 @@ function WebhookSubForm({ templateType }) { label: t`GitLab`, isDisabled: false, }, + { + value: 'bitbucket_dc', + key: 'bitbucket_dc', + label: t`Bitbucket Data Center`, + isDisabled: false, + }, ]; if (error || webhookKeyError) {