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/credential_plugins/aim.py b/awx/main/credential_plugins/aim.py index 048bd1b324d3..2476042b5f59 100644 --- a/awx/main/credential_plugins/aim.py +++ b/awx/main/credential_plugins/aim.py @@ -58,7 +58,7 @@ 'id': 'object_property', 'label': _('Object Property'), 'type': 'string', - 'help_text': _('The property of the object to return. Default: Content Ex: Username, Address, etc.'), + 'help_text': _('The property of the object to return. Available properties: Username, Password and Address.'), }, { 'id': 'reason', @@ -111,8 +111,12 @@ def aim_backend(**kwargs): object_property = 'Content' elif object_property.lower() == 'username': object_property = 'UserName' + elif object_property.lower() == 'password': + object_property = 'Content' + elif object_property.lower() == 'address': + object_property = 'Address' elif object_property not in res: - raise KeyError('Property {} not found in object'.format(object_property)) + raise KeyError('Property {} not found in object, available properties: Username, Password and Address'.format(object_property)) else: object_property = object_property.capitalize() 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) { diff --git a/awxkit/awxkit/cli/docs/source/conf.py b/awxkit/awxkit/cli/docs/source/conf.py index db66c6292c27..490ddb404bef 100644 --- a/awxkit/awxkit/cli/docs/source/conf.py +++ b/awxkit/awxkit/cli/docs/source/conf.py @@ -18,7 +18,7 @@ # -- Project information ----------------------------------------------------- project = 'AWX CLI' -copyright = '2019, Ansible by Red Hat' +copyright = '2024, Ansible by Red Hat' author = 'Ansible by Red Hat' diff --git a/tools/docker-compose/ansible/roles/sources/templates/docker-compose.yml.j2 b/tools/docker-compose/ansible/roles/sources/templates/docker-compose.yml.j2 index 1ffb052f8aa7..83dd2f178a0b 100644 --- a/tools/docker-compose/ansible/roles/sources/templates/docker-compose.yml.j2 +++ b/tools/docker-compose/ansible/roles/sources/templates/docker-compose.yml.j2 @@ -61,7 +61,7 @@ services: {% if control_plane_node_count|int == 1 %} - "6899:6899" - "8080:8080" # unused but mapped for debugging - - "8888:8888" # jupyter notebook + - "${AWX_JUPYTER_PORT:-8888}:8888" # jupyter notebook - "8013:8013" # http - "8043:8043" # https - "2222:2222" # receptor foo node @@ -201,6 +201,8 @@ services: POSTGRES_PASSWORD: {{ pg_password }} volumes: - "awx_db:/var/lib/postgresql/data" + ports: + - "${AWX_PG_PORT:-5432}:5432" {% if enable_pgbouncer|bool %} pgbouncer: image: bitnami/pgbouncer:latest