Skip to content

Commit

Permalink
Add support for Bitbucket Data Center webhooks (ansible#14674)
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
puiterwijk authored Jan 5, 2024
1 parent bb1922c commit 43be90f
Show file tree
Hide file tree
Showing 7 changed files with 190 additions and 15 deletions.
3 changes: 2 additions & 1 deletion awx/api/urls/webhooks.py
Original file line number Diff line number Diff line change
@@ -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'),
]
97 changes: 88 additions & 9 deletions awx/api/views/webhooks.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from hashlib import sha1
from hashlib import sha1, sha256
import hmac
import logging
import urllib.parse
Expand Down Expand Up @@ -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
Expand All @@ -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()
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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://<bitbucket-base-url>/rest/build-status/1.0/commits/<commit-hash>
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)
52 changes: 52 additions & 0 deletions awx/main/migrations/0188_add_bitbucket_dc_webhook.py
Original file line number Diff line number Diff line change
@@ -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,
),
),
]
19 changes: 19 additions & 0 deletions awx/main/models/credential/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
27 changes: 22 additions & 5 deletions awx/main/models/mixins.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'))
Expand Down Expand Up @@ -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': {
Expand All @@ -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]
Expand All @@ -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)
Expand Down
1 change: 1 addition & 0 deletions awx/main/tests/functional/test_credential.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ def test_default_cred_types():
'aws_secretsmanager_credential',
'azure_kv',
'azure_rm',
'bitbucket_dc_token',
'centrify_vault_kv',
'conjur',
'controller',
Expand Down
6 changes: 6 additions & 0 deletions awx/ui/src/screens/Template/shared/WebhookSubForm.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down

0 comments on commit 43be90f

Please sign in to comment.