Skip to content

Commit

Permalink
Merge branch 'devel' into hashi_vault_ldap_auth
Browse files Browse the repository at this point in the history
  • Loading branch information
djyasin authored Jan 5, 2024
2 parents 0bb1269 + 43be90f commit deb0fb5
Show file tree
Hide file tree
Showing 10 changed files with 200 additions and 19 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)
8 changes: 6 additions & 2 deletions awx/main/credential_plugins/aim.py
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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()

Expand Down
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
2 changes: 1 addition & 1 deletion awxkit/awxkit/cli/docs/source/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down

0 comments on commit deb0fb5

Please sign in to comment.