From d6116490c6cd4b5946c3d8b1a6d7ded9a1485291 Mon Sep 17 00:00:00 2001
From: Jeff Bradberry
Date: Tue, 6 Aug 2019 11:50:10 -0400
Subject: [PATCH 01/74] Add the webhook-specific fields to JobTemplate and
WorkflowJobTemplate
---
.../migrations/0092_v360_webhook_mixin.py | 44 +++++++++++++++++++
awx/main/models/jobs.py | 3 +-
awx/main/models/mixins.py | 34 ++++++++++++--
awx/main/models/workflow.py | 3 +-
4 files changed, 79 insertions(+), 5 deletions(-)
create mode 100644 awx/main/migrations/0092_v360_webhook_mixin.py
diff --git a/awx/main/migrations/0092_v360_webhook_mixin.py b/awx/main/migrations/0092_v360_webhook_mixin.py
new file mode 100644
index 0000000000..f2cde2a47f
--- /dev/null
+++ b/awx/main/migrations/0092_v360_webhook_mixin.py
@@ -0,0 +1,44 @@
+# Generated by Django 2.2.4 on 2019-09-12 14:49
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('main', '0091_v360_approval_node_notifications'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='jobtemplate',
+ name='webhook_credential',
+ field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='jobtemplates', to='main.Credential'),
+ ),
+ migrations.AddField(
+ model_name='jobtemplate',
+ name='webhook_key',
+ field=models.CharField(blank=True, max_length=64),
+ ),
+ migrations.AddField(
+ model_name='jobtemplate',
+ name='webhook_service',
+ field=models.CharField(blank=True, choices=[('github', 'Github'), ('gitlab', 'Gitlab'), ('bitbucket', 'Bitbucket')], max_length=16),
+ ),
+ migrations.AddField(
+ model_name='workflowjobtemplate',
+ name='webhook_credential',
+ field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='workflowjobtemplates', to='main.Credential'),
+ ),
+ migrations.AddField(
+ model_name='workflowjobtemplate',
+ name='webhook_key',
+ field=models.CharField(blank=True, max_length=64),
+ ),
+ migrations.AddField(
+ model_name='workflowjobtemplate',
+ name='webhook_service',
+ field=models.CharField(blank=True, choices=[('github', 'Github'), ('gitlab', 'Gitlab'), ('bitbucket', 'Bitbucket')], max_length=16),
+ ),
+ ]
diff --git a/awx/main/models/jobs.py b/awx/main/models/jobs.py
index a6395c495b..97fae67511 100644
--- a/awx/main/models/jobs.py
+++ b/awx/main/models/jobs.py
@@ -48,6 +48,7 @@ from awx.main.models.mixins import (
TaskManagerJobMixin,
CustomVirtualEnvMixin,
RelatedJobsMixin,
+ WebhookMixin,
)
@@ -187,7 +188,7 @@ class JobOptions(BaseModel):
return needed
-class JobTemplate(UnifiedJobTemplate, JobOptions, SurveyJobTemplateMixin, ResourceMixin, CustomVirtualEnvMixin, RelatedJobsMixin):
+class JobTemplate(UnifiedJobTemplate, JobOptions, SurveyJobTemplateMixin, ResourceMixin, CustomVirtualEnvMixin, RelatedJobsMixin, WebhookMixin):
'''
A job template is a reusable job definition for applying a project (with
playbook) to an inventory source with a given credential.
diff --git a/awx/main/models/mixins.py b/awx/main/models/mixins.py
index d63ec5eb5d..66195703f1 100644
--- a/awx/main/models/mixins.py
+++ b/awx/main/models/mixins.py
@@ -7,12 +7,12 @@ from copy import copy, deepcopy
# Django
from django.apps import apps
from django.conf import settings
-from django.db import models
-from django.contrib.contenttypes.models import ContentType
from django.contrib.auth.models import User # noqa
-from django.utils.translation import ugettext_lazy as _
+from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError
+from django.db import models
from django.db.models.query import QuerySet
+from django.utils.translation import ugettext_lazy as _
# AWX
from awx.main.models.base import prevent_search
@@ -483,3 +483,31 @@ class RelatedJobsMixin(object):
raise RuntimeError("Programmer error. Expected _get_active_jobs() to return a QuerySet.")
return [dict(id=t[0], type=mapping[t[1]]) for t in jobs.values_list('id', 'polymorphic_ctype_id')]
+
+
+class WebhookMixin(models.Model):
+ class Meta:
+ abstract = True
+
+ SERVICES = [
+ ('github', "Github"),
+ ('gitlab', "Gitlab"),
+ ('bitbucket', "Bitbucket"),
+ ]
+
+ webhook_service = models.CharField(
+ max_length=16,
+ choices=SERVICES,
+ blank=True
+ )
+ webhook_key = prevent_search(models.CharField(
+ max_length=64,
+ blank=True
+ ))
+ webhook_credential = models.ForeignKey(
+ 'Credential',
+ blank=True,
+ null=True,
+ on_delete=models.SET_NULL,
+ related_name='%(class)ss'
+ )
diff --git a/awx/main/models/workflow.py b/awx/main/models/workflow.py
index 29715d4cc8..cc3133740e 100644
--- a/awx/main/models/workflow.py
+++ b/awx/main/models/workflow.py
@@ -32,6 +32,7 @@ from awx.main.models.mixins import (
SurveyJobTemplateMixin,
SurveyJobMixin,
RelatedJobsMixin,
+ WebhookMixin,
)
from awx.main.models.jobs import LaunchTimeConfigBase, LaunchTimeConfig, JobTemplate
from awx.main.models.credential import Credential
@@ -358,7 +359,7 @@ class WorkflowJobOptions(LaunchTimeConfigBase):
return new_workflow_job
-class WorkflowJobTemplate(UnifiedJobTemplate, WorkflowJobOptions, SurveyJobTemplateMixin, ResourceMixin, RelatedJobsMixin):
+class WorkflowJobTemplate(UnifiedJobTemplate, WorkflowJobOptions, SurveyJobTemplateMixin, ResourceMixin, RelatedJobsMixin, WebhookMixin):
SOFT_UNIQUE_TOGETHER = [('polymorphic_ctype', 'name', 'organization')]
FIELDS_TO_PRESERVE_AT_COPY = [
From a7a99ed1415f43a3092b96739d13ff7aa10835ac Mon Sep 17 00:00:00 2001
From: Jeff Bradberry
Date: Tue, 6 Aug 2019 14:33:25 -0400
Subject: [PATCH 02/74] Beginnings of the API views for the webhook receivers
---
awx/api/views/webhooks.py | 94 +++++++++++++++++++++++++++++++++++++++
1 file changed, 94 insertions(+)
create mode 100644 awx/api/views/webhooks.py
diff --git a/awx/api/views/webhooks.py b/awx/api/views/webhooks.py
new file mode 100644
index 0000000000..328436d77a
--- /dev/null
+++ b/awx/api/views/webhooks.py
@@ -0,0 +1,94 @@
+from hashlib import sha1
+import hmac
+
+from django.utils.encoding import force_bytes
+from rest_framework.exceptions import PermissionDenied
+
+from awx.api.generics import APIView
+
+
+class WebhookReceiverBase(APIView):
+ def get_object(self):
+ queryset = self.queryset.filter(webhook_service=self.service)
+ lookup_url_kwarg = self.lookup_url_kwarg or self.lookup_field
+ filter_kwargs = {self.lookup_field: self.kwargs[lookup_url_kwarg]}
+
+ obj = queryset.filter(**filter_kwargs).first()
+ if obj is None:
+ raise PermissionDenied
+
+ return obj
+
+ def get_event_type(self):
+ raise NotImplementedError
+
+ def get_event_guid(self):
+ raise NotImplementedError
+
+ def get_signature(self):
+ raise NotImplementedError
+
+ def check_signature(self, obj):
+ mac = hmac.new(force_bytes(obj.webhook_key), msg=force_bytes(self.request.body), digestmod=sha1)
+ if not hmac.compare_digest(force_bytes(mac.hexdigest()), self.get_signature()):
+ raise PermissionDenied
+
+ def post(self, request, *args, **kwargs):
+ obj = self.get_object()
+ self.check_signature(obj)
+
+
+class GithubWebhookReceiver(WebhookReceiverBase):
+ service = 'github'
+
+ def get_event_type(self):
+ return self.request.META.get('HTTP_X_GITHUB_EVENT')
+
+ def get_event_guid(self):
+ return self.request.META.get('HTTP_X_GITHUB_DELIVERY')
+
+ def get_signature(self):
+ header_sig = self.request.META.get('HTTP_X_HUB_SIGNATURE')
+ if not header_sig:
+ raise PermissionDenied
+ hash_alg, signature = header_sig.split('=')
+ if hash_alg != 'sha1':
+ raise PermissionDenied
+ return force_bytes(signature)
+
+
+class GitlabWebhookReceiver(WebhookReceiverBase):
+ service = 'gitlab'
+
+ def get_event_type(self):
+ return self.request.META.get('HTTP_X_GITLAB_EVENT')
+
+ def get_event_guid(self):
+ # Gitlab does not provide a unique identifier on events.
+ return ''
+
+ def get_signature(self):
+ return self.request.META.get('HTTP_X_GITLAB_TOKEN')
+
+ def check_signature(self, obj):
+ # 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()):
+ raise PermissionDenied
+
+
+class BitbucketWebhookReceiver(WebhookReceiverBase):
+ service = 'bitbucket'
+
+ 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_UUID')
+
+ def get_signature(self):
+ header_sig = self.request.META.get('HTTP_X_HUB_SIGNATURE')
+ if not header_sig:
+ raise PermissionDenied
+ return force_bytes(header_sig)
From 8f97dbf781827e007ba3936ec92ef98a9826dac6 Mon Sep 17 00:00:00 2001
From: Jeff Bradberry
Date: Tue, 6 Aug 2019 16:00:59 -0400
Subject: [PATCH 03/74] Hook in the webhook receiver views into the urlconf
---
awx/api/urls/urls.py | 2 ++
awx/api/urls/webhooks.py | 14 ++++++++++++++
awx/api/views/__init__.py | 5 +++++
awx/api/views/webhooks.py | 17 ++++++++++++++++-
4 files changed, 37 insertions(+), 1 deletion(-)
create mode 100644 awx/api/urls/webhooks.py
diff --git a/awx/api/urls/urls.py b/awx/api/urls/urls.py
index ab7d61fd23..af62c29541 100644
--- a/awx/api/urls/urls.py
+++ b/awx/api/urls/urls.py
@@ -137,6 +137,8 @@ v2_urls = [
url(r'^activity_stream/', include(activity_stream_urls)),
url(r'^workflow_approval_templates/', include(workflow_approval_template_urls)),
url(r'^workflow_approvals/', include(workflow_approval_urls)),
+ url(r'^(?Pjob_templates|workflow_job_templates)/(?P[0-9]+)/',
+ include('awx.api.urls.webhooks')),
]
diff --git a/awx/api/urls/webhooks.py b/awx/api/urls/webhooks.py
new file mode 100644
index 0000000000..33fb697fd4
--- /dev/null
+++ b/awx/api/urls/webhooks.py
@@ -0,0 +1,14 @@
+from django.conf.urls import url
+
+from awx.api.views import (
+ GithubWebhookReceiver,
+ GitlabWebhookReceiver,
+ BitbucketWebhookReceiver,
+)
+
+
+urlpatterns = [
+ url(r'^github/$', GithubWebhookReceiver.as_view(), name='webhook_receiver_github'),
+ url(r'^gitlab/$', GitlabWebhookReceiver.as_view(), name='webhook_receiver_gitlab'),
+ url(r'^bitbucket/$', BitbucketWebhookReceiver.as_view(), name='webhook_receiver_bitbucket'),
+]
diff --git a/awx/api/views/__init__.py b/awx/api/views/__init__.py
index 7b831edfa5..9df5567ec3 100644
--- a/awx/api/views/__init__.py
+++ b/awx/api/views/__init__.py
@@ -150,6 +150,11 @@ from awx.api.views.root import ( # noqa
ApiV2ConfigView,
ApiV2SubscriptionView,
)
+from awx.api.views.webhooks import ( # noqa
+ GithubWebhookReceiver,
+ GitlabWebhookReceiver,
+ BitbucketWebhookReceiver,
+)
logger = logging.getLogger('awx.api.views')
diff --git a/awx/api/views/webhooks.py b/awx/api/views/webhooks.py
index 328436d77a..2728e4ac3b 100644
--- a/awx/api/views/webhooks.py
+++ b/awx/api/views/webhooks.py
@@ -5,11 +5,26 @@ from django.utils.encoding import force_bytes
from rest_framework.exceptions import PermissionDenied
from awx.api.generics import APIView
+from awx.main.models import JobTemplate, WorkflowJobTemplate
class WebhookReceiverBase(APIView):
+ lookup_url_kwarg = None
+ lookup_field = 'pk'
+
+ def get_queryset(self):
+ qs_models = {
+ 'job_templates': JobTemplate,
+ 'workflow_job_templates': WorkflowJobTemplate,
+ }
+ model = qs_models.get(self.kwargs['model_kwarg'])
+ if model is None:
+ raise PermissionDenied
+
+ return model.objects.filter(webhook_service=self.service)
+
def get_object(self):
- queryset = self.queryset.filter(webhook_service=self.service)
+ queryset = self.get_queryset()
lookup_url_kwarg = self.lookup_url_kwarg or self.lookup_field
filter_kwargs = {self.lookup_field: self.kwargs[lookup_url_kwarg]}
From 50a54c9214613519485d31aa52d694a0ed6e5441 Mon Sep 17 00:00:00 2001
From: Jeff Bradberry
Date: Wed, 7 Aug 2019 14:49:39 -0400
Subject: [PATCH 04/74] Forbid access to the webhook receiver views if
webhook_key is not set
---
awx/api/views/webhooks.py | 10 ++++++++--
1 file changed, 8 insertions(+), 2 deletions(-)
diff --git a/awx/api/views/webhooks.py b/awx/api/views/webhooks.py
index 2728e4ac3b..a88131e274 100644
--- a/awx/api/views/webhooks.py
+++ b/awx/api/views/webhooks.py
@@ -44,6 +44,9 @@ class WebhookReceiverBase(APIView):
raise NotImplementedError
def check_signature(self, obj):
+ if not obj.webhook_key:
+ raise PermissionDenied
+
mac = hmac.new(force_bytes(obj.webhook_key), msg=force_bytes(self.request.body), digestmod=sha1)
if not hmac.compare_digest(force_bytes(mac.hexdigest()), self.get_signature()):
raise PermissionDenied
@@ -86,9 +89,12 @@ class GitlabWebhookReceiver(WebhookReceiverBase):
return self.request.META.get('HTTP_X_GITLAB_TOKEN')
def check_signature(self, obj):
- # Gitlab only returns the secret token, not an hmac hash
+ if not obj.webhook_key:
+ raise PermissionDenied
- # Use the hmac `compare_digest` helper function to prevent timing analysis by attackers.
+ # 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()):
raise PermissionDenied
From b0c530402f6f4381c5017f89635f68e11fd73432 Mon Sep 17 00:00:00 2001
From: Jeff Bradberry
Date: Thu, 8 Aug 2019 14:58:57 -0400
Subject: [PATCH 05/74] Move the webhook url include from the top level urlconf
to the JT/WFJT urlconfs
---
awx/api/urls/job_template.py | 3 ++-
awx/api/urls/urls.py | 2 --
awx/api/urls/workflow_job_template.py | 3 ++-
3 files changed, 4 insertions(+), 4 deletions(-)
diff --git a/awx/api/urls/job_template.py b/awx/api/urls/job_template.py
index 922019d117..77252eb7e3 100644
--- a/awx/api/urls/job_template.py
+++ b/awx/api/urls/job_template.py
@@ -1,7 +1,7 @@
# Copyright (c) 2017 Ansible, Inc.
# All Rights Reserved.
-from django.conf.urls import url
+from django.conf.urls import include, url
from awx.api.views import (
JobTemplateList,
@@ -45,6 +45,7 @@ urls = [
url(r'^(?P[0-9]+)/object_roles/$', JobTemplateObjectRolesList.as_view(), name='job_template_object_roles_list'),
url(r'^(?P[0-9]+)/labels/$', JobTemplateLabelList.as_view(), name='job_template_label_list'),
url(r'^(?P[0-9]+)/copy/$', JobTemplateCopy.as_view(), name='job_template_copy'),
+ url(r'^(?P[0-9]+)/', include('awx.api.urls.webhooks'), {'model_kwarg': 'job_templates'}),
]
__all__ = ['urls']
diff --git a/awx/api/urls/urls.py b/awx/api/urls/urls.py
index af62c29541..ab7d61fd23 100644
--- a/awx/api/urls/urls.py
+++ b/awx/api/urls/urls.py
@@ -137,8 +137,6 @@ v2_urls = [
url(r'^activity_stream/', include(activity_stream_urls)),
url(r'^workflow_approval_templates/', include(workflow_approval_template_urls)),
url(r'^workflow_approvals/', include(workflow_approval_urls)),
- url(r'^(?Pjob_templates|workflow_job_templates)/(?P[0-9]+)/',
- include('awx.api.urls.webhooks')),
]
diff --git a/awx/api/urls/workflow_job_template.py b/awx/api/urls/workflow_job_template.py
index 349dad1aa5..b9deda499a 100644
--- a/awx/api/urls/workflow_job_template.py
+++ b/awx/api/urls/workflow_job_template.py
@@ -1,7 +1,7 @@
# Copyright (c) 2017 Ansible, Inc.
# All Rights Reserved.
-from django.conf.urls import url
+from django.conf.urls import include, url
from awx.api.views import (
WorkflowJobTemplateList,
@@ -44,6 +44,7 @@ urls = [
url(r'^(?P[0-9]+)/access_list/$', WorkflowJobTemplateAccessList.as_view(), name='workflow_job_template_access_list'),
url(r'^(?P[0-9]+)/object_roles/$', WorkflowJobTemplateObjectRolesList.as_view(), name='workflow_job_template_object_roles_list'),
url(r'^(?P[0-9]+)/labels/$', WorkflowJobTemplateLabelList.as_view(), name='workflow_job_template_label_list'),
+ url(r'^(?P[0-9]+)/', include('awx.api.urls.webhooks'), {'model_kwarg': 'workflow_job_templates'}),
]
__all__ = ['urls']
From 9d269d59d66c945965083e1e3e6dd270aa153a73 Mon Sep 17 00:00:00 2001
From: Jeff Bradberry
Date: Mon, 12 Aug 2019 15:49:15 -0400
Subject: [PATCH 06/74] Add an api view for obtaining and rotating the webhook
key
---
awx/api/urls/webhooks.py | 2 ++
awx/api/views/__init__.py | 1 +
awx/api/views/webhooks.py | 43 +++++++++++++++++++++++++++++++++++++--
awx/main/models/mixins.py | 5 +++++
4 files changed, 49 insertions(+), 2 deletions(-)
diff --git a/awx/api/urls/webhooks.py b/awx/api/urls/webhooks.py
index 33fb697fd4..3523d8f04d 100644
--- a/awx/api/urls/webhooks.py
+++ b/awx/api/urls/webhooks.py
@@ -1,6 +1,7 @@
from django.conf.urls import url
from awx.api.views import (
+ WebhookKeyView,
GithubWebhookReceiver,
GitlabWebhookReceiver,
BitbucketWebhookReceiver,
@@ -8,6 +9,7 @@ from awx.api.views import (
urlpatterns = [
+ url(r'^webhook_key/$', WebhookKeyView.as_view(), name='webhook_key'),
url(r'^github/$', GithubWebhookReceiver.as_view(), name='webhook_receiver_github'),
url(r'^gitlab/$', GitlabWebhookReceiver.as_view(), name='webhook_receiver_gitlab'),
url(r'^bitbucket/$', BitbucketWebhookReceiver.as_view(), name='webhook_receiver_bitbucket'),
diff --git a/awx/api/views/__init__.py b/awx/api/views/__init__.py
index 9df5567ec3..2cfce6f9b0 100644
--- a/awx/api/views/__init__.py
+++ b/awx/api/views/__init__.py
@@ -151,6 +151,7 @@ from awx.api.views.root import ( # noqa
ApiV2SubscriptionView,
)
from awx.api.views.webhooks import ( # noqa
+ WebhookKeyView,
GithubWebhookReceiver,
GitlabWebhookReceiver,
BitbucketWebhookReceiver,
diff --git a/awx/api/views/webhooks.py b/awx/api/views/webhooks.py
index a88131e274..66c8e51ced 100644
--- a/awx/api/views/webhooks.py
+++ b/awx/api/views/webhooks.py
@@ -2,16 +2,55 @@ from hashlib import sha1
import hmac
from django.utils.encoding import force_bytes
-from rest_framework.exceptions import PermissionDenied
+from django.views.decorators.csrf import csrf_exempt
-from awx.api.generics import APIView
+from rest_framework import status
+from rest_framework.exceptions import PermissionDenied
+from rest_framework.response import Response
+
+from awx.api import serializers
+from awx.api.generics import APIView, GenericAPIView
from awx.main.models import JobTemplate, WorkflowJobTemplate
+class WebhookKeyView(GenericAPIView):
+ serializer_class = serializers.EmptySerializer
+
+ @property
+ def model(self):
+ qs_models = {
+ 'job_templates': JobTemplate,
+ 'workflow_job_templates': WorkflowJobTemplate,
+ }
+ model = qs_models.get(self.kwargs['model_kwarg'])
+ if model is None:
+ raise PermissionDenied
+
+ return model
+
+ def get_queryset(self):
+ return self.request.user.get_queryset(self.model)
+
+ def get(self, request, *args, **kwargs):
+ obj = self.get_object()
+
+ return Response({'webhook_key': obj.webhook_key})
+
+ def post(self, request, *args, **kwargs):
+ obj = self.get_object()
+ obj.rotate_webhook_key()
+
+ return Response({'webhook_key': obj.webhook_key}, status=status.HTTP_201_CREATED)
+
+
class WebhookReceiverBase(APIView):
lookup_url_kwarg = None
lookup_field = 'pk'
+ @csrf_exempt
+ def dispatch(self, *args, **kwargs):
+ return super().dispatch(*args, **kwargs)
+
def get_queryset(self):
qs_models = {
'job_templates': JobTemplate,
diff --git a/awx/main/models/mixins.py b/awx/main/models/mixins.py
index 66195703f1..c0e37faec4 100644
--- a/awx/main/models/mixins.py
+++ b/awx/main/models/mixins.py
@@ -12,6 +12,7 @@ from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError
from django.db import models
from django.db.models.query import QuerySet
+from django.utils.crypto import get_random_string
from django.utils.translation import ugettext_lazy as _
# AWX
@@ -511,3 +512,7 @@ class WebhookMixin(models.Model):
on_delete=models.SET_NULL,
related_name='%(class)ss'
)
+
+ def rotate_webhook_key(self):
+ self.webhook_key = get_random_string(length=50)
+ self.save(update_fields=['webhook_key'])
From 747a2283d66baaa026ef73a678646c40ade8d435 Mon Sep 17 00:00:00 2001
From: Jeff Bradberry
Date: Wed, 14 Aug 2019 14:50:50 -0400
Subject: [PATCH 07/74] Attempt to get the RBAC right on the webhook secret key
view
---
awx/api/views/webhooks.py | 13 +++++++++----
1 file changed, 9 insertions(+), 4 deletions(-)
diff --git a/awx/api/views/webhooks.py b/awx/api/views/webhooks.py
index 66c8e51ced..7ba1831738 100644
--- a/awx/api/views/webhooks.py
+++ b/awx/api/views/webhooks.py
@@ -23,13 +23,18 @@ class WebhookKeyView(GenericAPIView):
'workflow_job_templates': WorkflowJobTemplate,
}
model = qs_models.get(self.kwargs['model_kwarg'])
- if model is None:
- raise PermissionDenied
-
return model
def get_queryset(self):
- return self.request.user.get_queryset(self.model)
+ model = self.model
+ if model:
+ return self.request.user.get_queryset(model)
+ # Provide a fallback do-nothing queryset so that get_object() has something to work with.
+ return JobTemplate.objects.none()
+
+ def check_object_permissions(self, request, obj):
+ if not request.user.can_access(self.model, 'admin', obj, request.data):
+ raise PermissionDenied
def get(self, request, *args, **kwargs):
obj = self.get_object()
From 7973a18103ab36eb9e06cf902cbb50702a52bc4c Mon Sep 17 00:00:00 2001
From: Jeff Bradberry
Date: Fri, 16 Aug 2019 11:52:21 -0400
Subject: [PATCH 08/74] Switch to using a permission class for the webhook
secret key view
This view is now behaving as expected for superuser, org admin, JT
admin, JT exec, and org member roles.
---
awx/api/permissions.py | 5 +++++
awx/api/views/webhooks.py | 6 ++----
2 files changed, 7 insertions(+), 4 deletions(-)
diff --git a/awx/api/permissions.py b/awx/api/permissions.py
index 34ee7f76fb..ecaabc4b91 100644
--- a/awx/api/permissions.py
+++ b/awx/api/permissions.py
@@ -249,3 +249,8 @@ class InstanceGroupTowerPermission(ModelAccessPermission):
if request.method == 'DELETE' and obj.name == "tower":
return False
return super(InstanceGroupTowerPermission, self).has_object_permission(request, view, obj)
+
+
+class WebhookKeyPermission(permissions.BasePermission):
+ def has_object_permission(self, request, view, obj):
+ return request.user.can_access(view.model, 'admin', obj, request.data)
diff --git a/awx/api/views/webhooks.py b/awx/api/views/webhooks.py
index 7ba1831738..eeec1edc05 100644
--- a/awx/api/views/webhooks.py
+++ b/awx/api/views/webhooks.py
@@ -10,11 +10,13 @@ from rest_framework.response import Response
from awx.api import serializers
from awx.api.generics import APIView, GenericAPIView
+from awx.api.permissions import WebhookKeyPermission
from awx.main.models import JobTemplate, WorkflowJobTemplate
class WebhookKeyView(GenericAPIView):
serializer_class = serializers.EmptySerializer
+ permission_classes = (WebhookKeyPermission,)
@property
def model(self):
@@ -32,10 +34,6 @@ class WebhookKeyView(GenericAPIView):
# Provide a fallback do-nothing queryset so that get_object() has something to work with.
return JobTemplate.objects.none()
- def check_object_permissions(self, request, obj):
- if not request.user.can_access(self.model, 'admin', obj, request.data):
- raise PermissionDenied
-
def get(self, request, *args, **kwargs):
obj = self.get_object()
From edb9d6b16c05312ba5014b3479e189391c6170cb Mon Sep 17 00:00:00 2001
From: Jeff Bradberry
Date: Fri, 16 Aug 2019 14:26:32 -0400
Subject: [PATCH 09/74] Add the related link to the webhook secrets view to the
serializers
---
awx/api/serializers.py | 51 +++++++++++++++++++++++-------------------
1 file changed, 28 insertions(+), 23 deletions(-)
diff --git a/awx/api/serializers.py b/awx/api/serializers.py
index 8100a78114..2b8834bb62 100644
--- a/awx/api/serializers.py
+++ b/awx/api/serializers.py
@@ -2838,30 +2838,34 @@ class JobTemplateSerializer(JobTemplateMixin, UnifiedJobTemplateSerializer, JobO
class Meta:
model = JobTemplate
- fields = ('*', 'host_config_key', 'ask_scm_branch_on_launch', 'ask_diff_mode_on_launch', 'ask_variables_on_launch',
- 'ask_limit_on_launch', 'ask_tags_on_launch',
- 'ask_skip_tags_on_launch', 'ask_job_type_on_launch', 'ask_verbosity_on_launch', 'ask_inventory_on_launch',
- 'ask_credential_on_launch', 'survey_enabled', 'become_enabled', 'diff_mode',
- 'allow_simultaneous', 'custom_virtualenv', 'job_slice_count')
+ fields = (
+ '*', 'host_config_key', 'ask_scm_branch_on_launch', 'ask_diff_mode_on_launch',
+ 'ask_variables_on_launch', 'ask_limit_on_launch', 'ask_tags_on_launch',
+ 'ask_skip_tags_on_launch', 'ask_job_type_on_launch', 'ask_verbosity_on_launch',
+ 'ask_inventory_on_launch', 'ask_credential_on_launch', 'survey_enabled',
+ 'become_enabled', 'diff_mode', 'allow_simultaneous', 'custom_virtualenv',
+ 'job_slice_count'
+ )
def get_related(self, obj):
res = super(JobTemplateSerializer, self).get_related(obj)
- res.update(dict(
- jobs = self.reverse('api:job_template_jobs_list', kwargs={'pk': obj.pk}),
- schedules = self.reverse('api:job_template_schedules_list', kwargs={'pk': obj.pk}),
- activity_stream = self.reverse('api:job_template_activity_stream_list', kwargs={'pk': obj.pk}),
- launch = self.reverse('api:job_template_launch', kwargs={'pk': obj.pk}),
- notification_templates_started = self.reverse('api:job_template_notification_templates_started_list', kwargs={'pk': obj.pk}),
- notification_templates_success = self.reverse('api:job_template_notification_templates_success_list', kwargs={'pk': obj.pk}),
- notification_templates_error = self.reverse('api:job_template_notification_templates_error_list', kwargs={'pk': obj.pk}),
- access_list = self.reverse('api:job_template_access_list', kwargs={'pk': obj.pk}),
- survey_spec = self.reverse('api:job_template_survey_spec', kwargs={'pk': obj.pk}),
- labels = self.reverse('api:job_template_label_list', kwargs={'pk': obj.pk}),
- object_roles = self.reverse('api:job_template_object_roles_list', kwargs={'pk': obj.pk}),
- instance_groups = self.reverse('api:job_template_instance_groups_list', kwargs={'pk': obj.pk}),
- slice_workflow_jobs = self.reverse('api:job_template_slice_workflow_jobs_list', kwargs={'pk': obj.pk}),
- copy = self.reverse('api:job_template_copy', kwargs={'pk': obj.pk}),
- ))
+ res.update(
+ jobs=self.reverse('api:job_template_jobs_list', kwargs={'pk': obj.pk}),
+ schedules=self.reverse('api:job_template_schedules_list', kwargs={'pk': obj.pk}),
+ activity_stream=self.reverse('api:job_template_activity_stream_list', kwargs={'pk': obj.pk}),
+ launch=self.reverse('api:job_template_launch', kwargs={'pk': obj.pk}),
+ webhook_key=self.reverse('api:webhook_key', kwargs={'model_kwarg': 'job_templates', 'pk': obj.pk}),
+ notification_templates_started=self.reverse('api:job_template_notification_templates_started_list', kwargs={'pk': obj.pk}),
+ notification_templates_success=self.reverse('api:job_template_notification_templates_success_list', kwargs={'pk': obj.pk}),
+ notification_templates_error=self.reverse('api:job_template_notification_templates_error_list', kwargs={'pk': obj.pk}),
+ access_list=self.reverse('api:job_template_access_list', kwargs={'pk': obj.pk}),
+ survey_spec=self.reverse('api:job_template_survey_spec', kwargs={'pk': obj.pk}),
+ labels=self.reverse('api:job_template_label_list', kwargs={'pk': obj.pk}),
+ object_roles=self.reverse('api:job_template_object_roles_list', kwargs={'pk': obj.pk}),
+ instance_groups=self.reverse('api:job_template_instance_groups_list', kwargs={'pk': obj.pk}),
+ slice_workflow_jobs=self.reverse('api:job_template_slice_workflow_jobs_list', kwargs={'pk': obj.pk}),
+ copy=self.reverse('api:job_template_copy', kwargs={'pk': obj.pk}),
+ )
if obj.host_config_key:
res['callback'] = self.reverse('api:job_template_callback', kwargs={'pk': obj.pk})
return res
@@ -3326,10 +3330,11 @@ class WorkflowJobTemplateSerializer(JobTemplateMixin, LabelsListMixin, UnifiedJo
def get_related(self, obj):
res = super(WorkflowJobTemplateSerializer, self).get_related(obj)
- res.update(dict(
+ res.update(
workflow_jobs = self.reverse('api:workflow_job_template_jobs_list', kwargs={'pk': obj.pk}),
schedules = self.reverse('api:workflow_job_template_schedules_list', kwargs={'pk': obj.pk}),
launch = self.reverse('api:workflow_job_template_launch', kwargs={'pk': obj.pk}),
+ webhook_key=self.reverse('api:webhook_key', kwargs={'model_kwarg': 'workflow_job_templates', 'pk': obj.pk}),
workflow_nodes = self.reverse('api:workflow_job_template_workflow_nodes_list', kwargs={'pk': obj.pk}),
labels = self.reverse('api:workflow_job_template_label_list', kwargs={'pk': obj.pk}),
activity_stream = self.reverse('api:workflow_job_template_activity_stream_list', kwargs={'pk': obj.pk}),
@@ -3341,7 +3346,7 @@ class WorkflowJobTemplateSerializer(JobTemplateMixin, LabelsListMixin, UnifiedJo
object_roles = self.reverse('api:workflow_job_template_object_roles_list', kwargs={'pk': obj.pk}),
survey_spec = self.reverse('api:workflow_job_template_survey_spec', kwargs={'pk': obj.pk}),
copy = self.reverse('api:workflow_job_template_copy', kwargs={'pk': obj.pk}),
- ))
+ )
if obj.organization:
res['organization'] = self.reverse('api:organization_detail', kwargs={'pk': obj.organization.pk})
return res
From 2310413dc06593625c0bfedbffba5b45d80dd7e7 Mon Sep 17 00:00:00 2001
From: Jeff Bradberry
Date: Fri, 16 Aug 2019 14:56:25 -0400
Subject: [PATCH 10/74] Fix problem with the tests by dynamically setting the
view model
instead of using a model @property or lookup method.
---
awx/api/urls/job_template.py | 3 ++-
awx/api/urls/workflow_job_template.py | 3 ++-
awx/api/views/webhooks.py | 30 ++++-----------------------
3 files changed, 8 insertions(+), 28 deletions(-)
diff --git a/awx/api/urls/job_template.py b/awx/api/urls/job_template.py
index 77252eb7e3..18305984bf 100644
--- a/awx/api/urls/job_template.py
+++ b/awx/api/urls/job_template.py
@@ -3,6 +3,7 @@
from django.conf.urls import include, url
+from awx.main.models import JobTemplate
from awx.api.views import (
JobTemplateList,
JobTemplateDetail,
@@ -45,7 +46,7 @@ urls = [
url(r'^(?P[0-9]+)/object_roles/$', JobTemplateObjectRolesList.as_view(), name='job_template_object_roles_list'),
url(r'^(?P[0-9]+)/labels/$', JobTemplateLabelList.as_view(), name='job_template_label_list'),
url(r'^(?P[0-9]+)/copy/$', JobTemplateCopy.as_view(), name='job_template_copy'),
- url(r'^(?P[0-9]+)/', include('awx.api.urls.webhooks'), {'model_kwarg': 'job_templates'}),
+ url(r'^(?P[0-9]+)/', include('awx.api.urls.webhooks'), {'model_kwarg': 'job_templates', 'model': JobTemplate}),
]
__all__ = ['urls']
diff --git a/awx/api/urls/workflow_job_template.py b/awx/api/urls/workflow_job_template.py
index b9deda499a..6d8608e6c1 100644
--- a/awx/api/urls/workflow_job_template.py
+++ b/awx/api/urls/workflow_job_template.py
@@ -3,6 +3,7 @@
from django.conf.urls import include, url
+from awx.main.models import WorkflowJobTemplate
from awx.api.views import (
WorkflowJobTemplateList,
WorkflowJobTemplateDetail,
@@ -44,7 +45,7 @@ urls = [
url(r'^(?P[0-9]+)/access_list/$', WorkflowJobTemplateAccessList.as_view(), name='workflow_job_template_access_list'),
url(r'^(?P[0-9]+)/object_roles/$', WorkflowJobTemplateObjectRolesList.as_view(), name='workflow_job_template_object_roles_list'),
url(r'^(?P[0-9]+)/labels/$', WorkflowJobTemplateLabelList.as_view(), name='workflow_job_template_label_list'),
- url(r'^(?P[0-9]+)/', include('awx.api.urls.webhooks'), {'model_kwarg': 'workflow_job_templates'}),
+ url(r'^(?P[0-9]+)/', include('awx.api.urls.webhooks'), {'model_kwarg': 'workflow_job_templates', 'model': WorkflowJobTemplate}),
]
__all__ = ['urls']
diff --git a/awx/api/views/webhooks.py b/awx/api/views/webhooks.py
index eeec1edc05..e9a7c7bbd3 100644
--- a/awx/api/views/webhooks.py
+++ b/awx/api/views/webhooks.py
@@ -11,29 +11,15 @@ from rest_framework.response import Response
from awx.api import serializers
from awx.api.generics import APIView, GenericAPIView
from awx.api.permissions import WebhookKeyPermission
-from awx.main.models import JobTemplate, WorkflowJobTemplate
+
+# NOTE: The model class attribute for these views must be added
+# dynamically when including urls/webhooks.py
class WebhookKeyView(GenericAPIView):
serializer_class = serializers.EmptySerializer
permission_classes = (WebhookKeyPermission,)
- @property
- def model(self):
- qs_models = {
- 'job_templates': JobTemplate,
- 'workflow_job_templates': WorkflowJobTemplate,
- }
- model = qs_models.get(self.kwargs['model_kwarg'])
- return model
-
- def get_queryset(self):
- model = self.model
- if model:
- return self.request.user.get_queryset(model)
- # Provide a fallback do-nothing queryset so that get_object() has something to work with.
- return JobTemplate.objects.none()
-
def get(self, request, *args, **kwargs):
obj = self.get_object()
@@ -55,15 +41,7 @@ class WebhookReceiverBase(APIView):
return super().dispatch(*args, **kwargs)
def get_queryset(self):
- qs_models = {
- 'job_templates': JobTemplate,
- 'workflow_job_templates': WorkflowJobTemplate,
- }
- model = qs_models.get(self.kwargs['model_kwarg'])
- if model is None:
- raise PermissionDenied
-
- return model.objects.filter(webhook_service=self.service)
+ return self.model.objects.filter(webhook_service=self.service)
def get_object(self):
queryset = self.get_queryset()
From 771ef275d42b390b75f1322a5bb4c782c9019203 Mon Sep 17 00:00:00 2001
From: Jeff Bradberry
Date: Fri, 16 Aug 2019 15:54:25 -0400
Subject: [PATCH 11/74] Include a check for the webhook_key related resource
url
in the tests for JTs and WFJTs.
---
.../tests/unit/api/serializers/test_job_template_serializers.py | 1 +
awx/main/tests/unit/api/serializers/test_workflow_serializers.py | 1 +
2 files changed, 2 insertions(+)
diff --git a/awx/main/tests/unit/api/serializers/test_job_template_serializers.py b/awx/main/tests/unit/api/serializers/test_job_template_serializers.py
index 698d27cb7c..730d74229b 100644
--- a/awx/main/tests/unit/api/serializers/test_job_template_serializers.py
+++ b/awx/main/tests/unit/api/serializers/test_job_template_serializers.py
@@ -50,6 +50,7 @@ class TestJobTemplateSerializerGetRelated():
'schedules',
'activity_stream',
'launch',
+ 'webhook_key',
'notification_templates_started',
'notification_templates_success',
'notification_templates_error',
diff --git a/awx/main/tests/unit/api/serializers/test_workflow_serializers.py b/awx/main/tests/unit/api/serializers/test_workflow_serializers.py
index 6cec577129..65837045f8 100644
--- a/awx/main/tests/unit/api/serializers/test_workflow_serializers.py
+++ b/awx/main/tests/unit/api/serializers/test_workflow_serializers.py
@@ -32,6 +32,7 @@ class TestWorkflowJobTemplateSerializerGetRelated():
'workflow_jobs',
'launch',
'workflow_nodes',
+ 'webhook_key',
])
def test_get_related(self, mocker, test_get_related, workflow_job_template, related_resource_name):
test_get_related(WorkflowJobTemplateSerializer,
From 6b86cf6e86bc4394c18bcd598e442838de7607d5 Mon Sep 17 00:00:00 2001
From: Jeff Bradberry
Date: Mon, 19 Aug 2019 15:13:34 -0400
Subject: [PATCH 12/74] Revert to using the explicit dispatch to the
appropriate model
since passing the model class at url include time doesn't work.
---
awx/api/urls/job_template.py | 3 +--
awx/api/urls/workflow_job_template.py | 3 +--
awx/api/views/webhooks.py | 20 +++++++++++++++++++-
3 files changed, 21 insertions(+), 5 deletions(-)
diff --git a/awx/api/urls/job_template.py b/awx/api/urls/job_template.py
index 18305984bf..77252eb7e3 100644
--- a/awx/api/urls/job_template.py
+++ b/awx/api/urls/job_template.py
@@ -3,7 +3,6 @@
from django.conf.urls import include, url
-from awx.main.models import JobTemplate
from awx.api.views import (
JobTemplateList,
JobTemplateDetail,
@@ -46,7 +45,7 @@ urls = [
url(r'^(?P[0-9]+)/object_roles/$', JobTemplateObjectRolesList.as_view(), name='job_template_object_roles_list'),
url(r'^(?P[0-9]+)/labels/$', JobTemplateLabelList.as_view(), name='job_template_label_list'),
url(r'^(?P[0-9]+)/copy/$', JobTemplateCopy.as_view(), name='job_template_copy'),
- url(r'^(?P[0-9]+)/', include('awx.api.urls.webhooks'), {'model_kwarg': 'job_templates', 'model': JobTemplate}),
+ url(r'^(?P[0-9]+)/', include('awx.api.urls.webhooks'), {'model_kwarg': 'job_templates'}),
]
__all__ = ['urls']
diff --git a/awx/api/urls/workflow_job_template.py b/awx/api/urls/workflow_job_template.py
index 6d8608e6c1..b9deda499a 100644
--- a/awx/api/urls/workflow_job_template.py
+++ b/awx/api/urls/workflow_job_template.py
@@ -3,7 +3,6 @@
from django.conf.urls import include, url
-from awx.main.models import WorkflowJobTemplate
from awx.api.views import (
WorkflowJobTemplateList,
WorkflowJobTemplateDetail,
@@ -45,7 +44,7 @@ urls = [
url(r'^(?P[0-9]+)/access_list/$', WorkflowJobTemplateAccessList.as_view(), name='workflow_job_template_access_list'),
url(r'^(?P[0-9]+)/object_roles/$', WorkflowJobTemplateObjectRolesList.as_view(), name='workflow_job_template_object_roles_list'),
url(r'^(?P[0-9]+)/labels/$', WorkflowJobTemplateLabelList.as_view(), name='workflow_job_template_label_list'),
- url(r'^(?P[0-9]+)/', include('awx.api.urls.webhooks'), {'model_kwarg': 'workflow_job_templates', 'model': WorkflowJobTemplate}),
+ url(r'^(?P[0-9]+)/', include('awx.api.urls.webhooks'), {'model_kwarg': 'workflow_job_templates'}),
]
__all__ = ['urls']
diff --git a/awx/api/views/webhooks.py b/awx/api/views/webhooks.py
index e9a7c7bbd3..25fb4b56bb 100644
--- a/awx/api/views/webhooks.py
+++ b/awx/api/views/webhooks.py
@@ -11,6 +11,7 @@ from rest_framework.response import Response
from awx.api import serializers
from awx.api.generics import APIView, GenericAPIView
from awx.api.permissions import WebhookKeyPermission
+from awx.main.models import JobTemplate, WorkflowJobTemplate
# NOTE: The model class attribute for these views must be added
# dynamically when including urls/webhooks.py
@@ -20,6 +21,15 @@ class WebhookKeyView(GenericAPIView):
serializer_class = serializers.EmptySerializer
permission_classes = (WebhookKeyPermission,)
+ def get_queryset(self):
+ qs_models = {
+ 'job_templates': JobTemplate,
+ 'workflow_job_templates': WorkflowJobTemplate,
+ }
+ self.model = qs_models.get(self.kwargs['model_kwarg'])
+
+ return super().get_queryset()
+
def get(self, request, *args, **kwargs):
obj = self.get_object()
@@ -41,7 +51,15 @@ class WebhookReceiverBase(APIView):
return super().dispatch(*args, **kwargs)
def get_queryset(self):
- return self.model.objects.filter(webhook_service=self.service)
+ qs_models = {
+ 'job_templates': JobTemplate,
+ 'workflow_job_templates': WorkflowJobTemplate,
+ }
+ model = qs_models.get(self.kwargs['model_kwarg'])
+ if model is None:
+ raise PermissionDenied
+
+ return model.objects.filter(webhook_service=self.service)
def get_object(self):
queryset = self.get_queryset()
From d9ac2911156247956c83bea7c593bbb604320d79 Mon Sep 17 00:00:00 2001
From: Jeff Bradberry
Date: Mon, 19 Aug 2019 15:32:14 -0400
Subject: [PATCH 13/74] Add some RBAC oriented tests for the webhook secret key
view
---
.../tests/functional/api/test_webhooks.py | 113 ++++++++++++++++++
1 file changed, 113 insertions(+)
create mode 100644 awx/main/tests/functional/api/test_webhooks.py
diff --git a/awx/main/tests/functional/api/test_webhooks.py b/awx/main/tests/functional/api/test_webhooks.py
new file mode 100644
index 0000000000..7bcbf1b1e5
--- /dev/null
+++ b/awx/main/tests/functional/api/test_webhooks.py
@@ -0,0 +1,113 @@
+import pytest
+
+from awx.api.versioning import reverse
+
+
+@pytest.mark.django_db
+@pytest.mark.parametrize(
+ "user_role, expect", [
+ ('superuser', 200),
+ ('org admin', 200),
+ ('jt admin', 200),
+ ('jt execute', 403),
+ ('org member', 403),
+ ]
+)
+def test_get_webhook_key_jt(organization_factory, job_template_factory, get, user_role, expect):
+ objs = organization_factory("org", superusers=['admin'], users=['user'])
+ jt = job_template_factory("jt", organization=objs.organization,
+ inventory='test_inv', project='test_proj').job_template
+ if user_role == 'superuser':
+ user = objs.superusers.admin
+ else:
+ user = objs.users.user
+ grant_obj = objs.organization if user_role.startswith('org') else jt
+ getattr(grant_obj, '{}_role'.format(user_role.split()[1])).members.add(user)
+
+ url = reverse('api:webhook_key', kwargs={'model_kwarg': 'job_templates', 'pk': jt.pk})
+ response = get(url, user=user)
+ assert response.status_code == expect
+ if expect < 400:
+ assert response.data == {'webhook_key': ''}
+
+
+@pytest.mark.django_db
+@pytest.mark.parametrize(
+ "user_role, expect", [
+ ('superuser', 200),
+ ('org admin', 200),
+ ('jt admin', 200),
+ ('jt execute', 403),
+ ('org member', 403),
+ ]
+)
+def test_get_webhook_key_wfjt(organization_factory, workflow_job_template_factory, get, user_role, expect):
+ objs = organization_factory("org", superusers=['admin'], users=['user'])
+ wfjt = workflow_job_template_factory("wfjt", organization=objs.organization).workflow_job_template
+ if user_role == 'superuser':
+ user = objs.superusers.admin
+ else:
+ user = objs.users.user
+ grant_obj = objs.organization if user_role.startswith('org') else wfjt
+ getattr(grant_obj, '{}_role'.format(user_role.split()[1])).members.add(user)
+
+ url = reverse('api:webhook_key', kwargs={'model_kwarg': 'workflow_job_templates', 'pk': wfjt.pk})
+ response = get(url, user=user)
+ assert response.status_code == expect
+ if expect < 400:
+ assert response.data == {'webhook_key': ''}
+
+
+@pytest.mark.django_db
+@pytest.mark.parametrize(
+ "user_role, expect", [
+ ('superuser', 201),
+ ('org admin', 201),
+ ('jt admin', 201),
+ ('jt execute', 403),
+ ('org member', 403),
+ ]
+)
+def test_post_webhook_key_jt(organization_factory, job_template_factory, post, user_role, expect):
+ objs = organization_factory("org", superusers=['admin'], users=['user'])
+ jt = job_template_factory("jt", organization=objs.organization,
+ inventory='test_inv', project='test_proj').job_template
+ if user_role == 'superuser':
+ user = objs.superusers.admin
+ else:
+ user = objs.users.user
+ grant_obj = objs.organization if user_role.startswith('org') else jt
+ getattr(grant_obj, '{}_role'.format(user_role.split()[1])).members.add(user)
+
+ url = reverse('api:webhook_key', kwargs={'model_kwarg': 'job_templates', 'pk': jt.pk})
+ response = post(url, {}, user=user)
+ assert response.status_code == expect
+ if expect < 400:
+ assert bool(response.data.get('webhook_key'))
+
+
+@pytest.mark.django_db
+@pytest.mark.parametrize(
+ "user_role, expect", [
+ ('superuser', 201),
+ ('org admin', 201),
+ ('jt admin', 201),
+ ('jt execute', 403),
+ ('org member', 403),
+ ]
+)
+def test_post_webhook_key_wfjt(organization_factory, workflow_job_template_factory, post, user_role, expect):
+ objs = organization_factory("org", superusers=['admin'], users=['user'])
+ wfjt = workflow_job_template_factory("wfjt", organization=objs.organization).workflow_job_template
+ if user_role == 'superuser':
+ user = objs.superusers.admin
+ else:
+ user = objs.users.user
+ grant_obj = objs.organization if user_role.startswith('org') else wfjt
+ getattr(grant_obj, '{}_role'.format(user_role.split()[1])).members.add(user)
+
+ url = reverse('api:webhook_key', kwargs={'model_kwarg': 'workflow_job_templates', 'pk': wfjt.pk})
+ response = post(url, {}, user=user)
+ assert response.status_code == expect
+ if expect < 400:
+ assert bool(response.data.get('webhook_key'))
From c0ad5a7768527b736968763bb021fdc78010be9c Mon Sep 17 00:00:00 2001
From: Jeff Bradberry
Date: Mon, 19 Aug 2019 15:57:26 -0400
Subject: [PATCH 14/74] Expose the webhook_service and webhook_credential
fields in the serializer
webhook_credential specifically as a summary field.
---
awx/api/serializers.py | 15 +++++++++------
1 file changed, 9 insertions(+), 6 deletions(-)
diff --git a/awx/api/serializers.py b/awx/api/serializers.py
index 2b8834bb62..c87b596782 100644
--- a/awx/api/serializers.py
+++ b/awx/api/serializers.py
@@ -118,8 +118,8 @@ SUMMARIZABLE_FK_FIELDS = {
'project_update': DEFAULT_SUMMARY_FIELDS + ('status', 'failed',),
'credential': DEFAULT_SUMMARY_FIELDS + ('kind', 'cloud', 'credential_type_id'),
'job': DEFAULT_SUMMARY_FIELDS + ('status', 'failed', 'elapsed', 'type'),
- 'job_template': DEFAULT_SUMMARY_FIELDS,
- 'workflow_job_template': DEFAULT_SUMMARY_FIELDS,
+ 'job_template': DEFAULT_SUMMARY_FIELDS + ('webhook_credential',),
+ 'workflow_job_template': DEFAULT_SUMMARY_FIELDS + ('webhook_credential',),
'workflow_job': DEFAULT_SUMMARY_FIELDS,
'workflow_approval_template': DEFAULT_SUMMARY_FIELDS + ('timeout',),
'workflow_approval': DEFAULT_SUMMARY_FIELDS + ('timeout',),
@@ -2844,7 +2844,7 @@ class JobTemplateSerializer(JobTemplateMixin, UnifiedJobTemplateSerializer, JobO
'ask_skip_tags_on_launch', 'ask_job_type_on_launch', 'ask_verbosity_on_launch',
'ask_inventory_on_launch', 'ask_credential_on_launch', 'survey_enabled',
'become_enabled', 'diff_mode', 'allow_simultaneous', 'custom_virtualenv',
- 'job_slice_count'
+ 'job_slice_count', 'webhook_service', 'webhook_credential',
)
def get_related(self, obj):
@@ -3324,9 +3324,12 @@ class WorkflowJobTemplateSerializer(JobTemplateMixin, LabelsListMixin, UnifiedJo
class Meta:
model = WorkflowJobTemplate
- fields = ('*', 'extra_vars', 'organization', 'survey_enabled', 'allow_simultaneous',
- 'ask_variables_on_launch', 'inventory', 'limit', 'scm_branch',
- 'ask_inventory_on_launch', 'ask_scm_branch_on_launch', 'ask_limit_on_launch',)
+ fields = (
+ '*', 'extra_vars', 'organization', 'survey_enabled', 'allow_simultaneous',
+ 'ask_variables_on_launch', 'inventory', 'limit', 'scm_branch',
+ 'ask_inventory_on_launch', 'ask_scm_branch_on_launch', 'ask_limit_on_launch',
+ 'webhook_service', 'webhook_credential',
+ )
def get_related(self, obj):
res = super(WorkflowJobTemplateSerializer, self).get_related(obj)
From d4b20b7340ce979afa5c5409f8cce826bcb47b09 Mon Sep 17 00:00:00 2001
From: Jeff Bradberry
Date: Tue, 20 Aug 2019 14:01:19 -0400
Subject: [PATCH 15/74] Update tests to use the `expect` keyword argument for
get() and post()
---
awx/main/tests/functional/api/test_webhooks.py | 12 ++++--------
1 file changed, 4 insertions(+), 8 deletions(-)
diff --git a/awx/main/tests/functional/api/test_webhooks.py b/awx/main/tests/functional/api/test_webhooks.py
index 7bcbf1b1e5..183ab13793 100644
--- a/awx/main/tests/functional/api/test_webhooks.py
+++ b/awx/main/tests/functional/api/test_webhooks.py
@@ -25,8 +25,7 @@ def test_get_webhook_key_jt(organization_factory, job_template_factory, get, use
getattr(grant_obj, '{}_role'.format(user_role.split()[1])).members.add(user)
url = reverse('api:webhook_key', kwargs={'model_kwarg': 'job_templates', 'pk': jt.pk})
- response = get(url, user=user)
- assert response.status_code == expect
+ response = get(url, user=user, expect=expect)
if expect < 400:
assert response.data == {'webhook_key': ''}
@@ -52,8 +51,7 @@ def test_get_webhook_key_wfjt(organization_factory, workflow_job_template_factor
getattr(grant_obj, '{}_role'.format(user_role.split()[1])).members.add(user)
url = reverse('api:webhook_key', kwargs={'model_kwarg': 'workflow_job_templates', 'pk': wfjt.pk})
- response = get(url, user=user)
- assert response.status_code == expect
+ response = get(url, user=user, expect=expect)
if expect < 400:
assert response.data == {'webhook_key': ''}
@@ -80,8 +78,7 @@ def test_post_webhook_key_jt(organization_factory, job_template_factory, post, u
getattr(grant_obj, '{}_role'.format(user_role.split()[1])).members.add(user)
url = reverse('api:webhook_key', kwargs={'model_kwarg': 'job_templates', 'pk': jt.pk})
- response = post(url, {}, user=user)
- assert response.status_code == expect
+ response = post(url, {}, user=user, expect=expect)
if expect < 400:
assert bool(response.data.get('webhook_key'))
@@ -107,7 +104,6 @@ def test_post_webhook_key_wfjt(organization_factory, workflow_job_template_facto
getattr(grant_obj, '{}_role'.format(user_role.split()[1])).members.add(user)
url = reverse('api:webhook_key', kwargs={'model_kwarg': 'workflow_job_templates', 'pk': wfjt.pk})
- response = post(url, {}, user=user)
- assert response.status_code == expect
+ response = post(url, {}, user=user, expect=expect)
if expect < 400:
assert bool(response.data.get('webhook_key'))
From 82a0dc0024c24f4fafe23eee9ce208acfc8b20fc Mon Sep 17 00:00:00 2001
From: Jeff Bradberry
Date: Thu, 22 Aug 2019 14:17:30 -0400
Subject: [PATCH 16/74] Cycle or unset the webhook key if the webhook service
changes
Also, tests.
---
awx/api/views/webhooks.py | 1 +
awx/main/models/mixins.py | 15 ++++++-
awx/main/tests/factories/fixtures.py | 10 +++--
awx/main/tests/factories/tower.py | 12 +++---
.../tests/functional/api/test_webhooks.py | 39 +++++++++++++++++++
5 files changed, 66 insertions(+), 11 deletions(-)
diff --git a/awx/api/views/webhooks.py b/awx/api/views/webhooks.py
index 25fb4b56bb..247e13fe54 100644
--- a/awx/api/views/webhooks.py
+++ b/awx/api/views/webhooks.py
@@ -38,6 +38,7 @@ class WebhookKeyView(GenericAPIView):
def post(self, request, *args, **kwargs):
obj = self.get_object()
obj.rotate_webhook_key()
+ obj.save(update_fields=['webhook_key'])
return Response({'webhook_key': obj.webhook_key}, status=status.HTTP_201_CREATED)
diff --git a/awx/main/models/mixins.py b/awx/main/models/mixins.py
index c0e37faec4..2ab8fc786e 100644
--- a/awx/main/models/mixins.py
+++ b/awx/main/models/mixins.py
@@ -515,4 +515,17 @@ class WebhookMixin(models.Model):
def rotate_webhook_key(self):
self.webhook_key = get_random_string(length=50)
- self.save(update_fields=['webhook_key'])
+
+ def save(self, *args, **kwargs):
+ update_fields = kwargs.get('update_fields')
+
+ if not self.pk or self._values_have_edits({'webhook_service': self.webhook_service}):
+ if self.webhook_service:
+ self.rotate_webhook_key()
+ else:
+ self.webhook_key = ''
+
+ if update_fields and 'webhook_service' in update_fields:
+ update_fields.append('webhook_key')
+
+ super().save(*args, **kwargs)
diff --git a/awx/main/tests/factories/fixtures.py b/awx/main/tests/factories/fixtures.py
index fe61410908..2f8cbe6934 100644
--- a/awx/main/tests/factories/fixtures.py
+++ b/awx/main/tests/factories/fixtures.py
@@ -154,12 +154,12 @@ def mk_job_template(name, job_type='run',
organization=None, inventory=None,
credential=None, network_credential=None,
cloud_credential=None, persisted=True, extra_vars='',
- project=None, spec=None):
+ project=None, spec=None, webhook_service=''):
if extra_vars:
extra_vars = json.dumps(extra_vars)
jt = JobTemplate(name=name, job_type=job_type, extra_vars=extra_vars,
- playbook='helloworld.yml')
+ webhook_service=webhook_service, playbook='helloworld.yml')
jt.inventory = inventory
if jt.inventory is None:
@@ -200,11 +200,13 @@ def mk_workflow_job(status='new', workflow_job_template=None, extra_vars={},
return job
-def mk_workflow_job_template(name, extra_vars='', spec=None, organization=None, persisted=True):
+def mk_workflow_job_template(name, extra_vars='', spec=None, organization=None, persisted=True,
+ webhook_service=''):
if extra_vars:
extra_vars = json.dumps(extra_vars)
- wfjt = WorkflowJobTemplate(name=name, extra_vars=extra_vars, organization=organization)
+ wfjt = WorkflowJobTemplate(name=name, extra_vars=extra_vars, organization=organization,
+ webhook_service=webhook_service)
wfjt.survey_spec = spec
if wfjt.survey_spec:
diff --git a/awx/main/tests/factories/tower.py b/awx/main/tests/factories/tower.py
index e8b0cc6e42..bfa7f9fc1b 100644
--- a/awx/main/tests/factories/tower.py
+++ b/awx/main/tests/factories/tower.py
@@ -197,7 +197,7 @@ def create_survey_spec(variables=None, default_type='integer', required=True, mi
#
-def create_job_template(name, roles=None, persisted=True, **kwargs):
+def create_job_template(name, roles=None, persisted=True, webhook_service='', **kwargs):
Objects = generate_objects(["job_template", "jobs",
"organization",
"inventory",
@@ -252,11 +252,10 @@ def create_job_template(name, roles=None, persisted=True, **kwargs):
else:
spec = None
- jt = mk_job_template(name, project=proj,
- inventory=inv, credential=cred,
+ jt = mk_job_template(name, project=proj, inventory=inv, credential=cred,
network_credential=net_cred, cloud_credential=cloud_cred,
job_type=job_type, spec=spec, extra_vars=extra_vars,
- persisted=persisted)
+ persisted=persisted, webhook_service=webhook_service)
if 'jobs' in kwargs:
for i in kwargs['jobs']:
@@ -401,7 +400,7 @@ def generate_workflow_job_template_nodes(workflow_job_template,
# TODO: Implement survey and jobs
-def create_workflow_job_template(name, organization=None, persisted=True, **kwargs):
+def create_workflow_job_template(name, organization=None, persisted=True, webhook_service='', **kwargs):
Objects = generate_objects(["workflow_job_template",
"workflow_job_template_nodes",
"survey",], kwargs)
@@ -418,7 +417,8 @@ def create_workflow_job_template(name, organization=None, persisted=True, **kwar
organization=organization,
spec=spec,
extra_vars=extra_vars,
- persisted=persisted)
+ persisted=persisted,
+ webhook_service=webhook_service)
diff --git a/awx/main/tests/functional/api/test_webhooks.py b/awx/main/tests/functional/api/test_webhooks.py
index 183ab13793..3e48906fc7 100644
--- a/awx/main/tests/functional/api/test_webhooks.py
+++ b/awx/main/tests/functional/api/test_webhooks.py
@@ -1,6 +1,7 @@
import pytest
from awx.api.versioning import reverse
+from awx.main.models.mixins import WebhookMixin
@pytest.mark.django_db
@@ -107,3 +108,41 @@ def test_post_webhook_key_wfjt(organization_factory, workflow_job_template_facto
response = post(url, {}, user=user, expect=expect)
if expect < 400:
assert bool(response.data.get('webhook_key'))
+
+
+@pytest.mark.django_db
+@pytest.mark.parametrize(
+ "service", [s for s, _ in WebhookMixin.SERVICES]
+)
+def test_set_webhook_service(organization_factory, job_template_factory, patch, service):
+ objs = organization_factory("org", superusers=['admin'])
+ jt = job_template_factory("jt", organization=objs.organization,
+ inventory='test_inv', project='test_proj').job_template
+ admin = objs.superusers.admin
+ assert (jt.webhook_service, jt.webhook_key) == ('', '')
+
+ url = reverse('api:job_template_detail', kwargs={'pk': jt.pk})
+ patch(url, {'webhook_service': service}, user=admin, expect=200)
+ jt.refresh_from_db()
+
+ assert jt.webhook_service == service
+ assert jt.webhook_key != ''
+
+
+@pytest.mark.django_db
+@pytest.mark.parametrize(
+ "service", [s for s, _ in WebhookMixin.SERVICES]
+)
+def test_unset_webhook_service(organization_factory, job_template_factory, patch, service):
+ objs = organization_factory("org", superusers=['admin'])
+ jt = job_template_factory("jt", organization=objs.organization, webhook_service=service,
+ inventory='test_inv', project='test_proj').job_template
+ admin = objs.superusers.admin
+ assert jt.webhook_service == service
+ assert jt.webhook_key != ''
+
+ url = reverse('api:job_template_detail', kwargs={'pk': jt.pk})
+ patch(url, {'webhook_service': ''}, user=admin, expect=200)
+ jt.refresh_from_db()
+
+ assert (jt.webhook_service, jt.webhook_key) == ('', '')
From fa15696ffe6dcd894a4a903e193d43050aeafd68 Mon Sep 17 00:00:00 2001
From: Jeff Bradberry
Date: Tue, 27 Aug 2019 14:29:54 -0400
Subject: [PATCH 17/74] Remove some dead comments
---
awx/api/views/webhooks.py | 3 ---
awx/urls.py | 4 ----
2 files changed, 7 deletions(-)
diff --git a/awx/api/views/webhooks.py b/awx/api/views/webhooks.py
index 247e13fe54..783611ced5 100644
--- a/awx/api/views/webhooks.py
+++ b/awx/api/views/webhooks.py
@@ -13,9 +13,6 @@ from awx.api.generics import APIView, GenericAPIView
from awx.api.permissions import WebhookKeyPermission
from awx.main.models import JobTemplate, WorkflowJobTemplate
-# NOTE: The model class attribute for these views must be added
-# dynamically when including urls/webhooks.py
-
class WebhookKeyView(GenericAPIView):
serializer_class = serializers.EmptySerializer
diff --git a/awx/urls.py b/awx/urls.py
index d16ccdf67c..970047151d 100644
--- a/awx/urls.py
+++ b/awx/urls.py
@@ -28,10 +28,6 @@ if settings.SETTINGS_MODULE == 'awx.settings.development':
try:
import debug_toolbar
urlpatterns += [
- # for Django version 2.0
- # path('__debug__/', include(debug_toolbar.urls)),
-
- # TODO: this is the Django < 2.0 version, REMOVEME
url(r'^__debug__/', include(debug_toolbar.urls))
]
except ImportError:
From 66a81869950560cbe2726f49921fc66608454f1a Mon Sep 17 00:00:00 2001
From: Jeff Bradberry
Date: Tue, 27 Aug 2019 16:39:10 -0400
Subject: [PATCH 18/74] Get the webhook receiver views to work at least
minimally
---
awx/api/views/webhooks.py | 19 +++++++++++++++----
1 file changed, 15 insertions(+), 4 deletions(-)
diff --git a/awx/api/views/webhooks.py b/awx/api/views/webhooks.py
index 783611ced5..45b92b63c1 100644
--- a/awx/api/views/webhooks.py
+++ b/awx/api/views/webhooks.py
@@ -1,11 +1,13 @@
from hashlib import sha1
import hmac
+import logging
from django.utils.encoding import force_bytes
from django.views.decorators.csrf import csrf_exempt
from rest_framework import status
from rest_framework.exceptions import PermissionDenied
+from rest_framework.permissions import AllowAny
from rest_framework.response import Response
from awx.api import serializers
@@ -14,6 +16,9 @@ from awx.api.permissions import WebhookKeyPermission
from awx.main.models import JobTemplate, WorkflowJobTemplate
+logger = logging.getLogger('awx.api.views.webhooks')
+
+
class WebhookKeyView(GenericAPIView):
serializer_class = serializers.EmptySerializer
permission_classes = (WebhookKeyPermission,)
@@ -44,9 +49,7 @@ class WebhookReceiverBase(APIView):
lookup_url_kwarg = None
lookup_field = 'pk'
- @csrf_exempt
- def dispatch(self, *args, **kwargs):
- return super().dispatch(*args, **kwargs)
+ permission_classes = (AllowAny,)
def get_queryset(self):
qs_models = {
@@ -83,14 +86,22 @@ class WebhookReceiverBase(APIView):
if not obj.webhook_key:
raise PermissionDenied
- mac = hmac.new(force_bytes(obj.webhook_key), msg=force_bytes(self.request.body), digestmod=sha1)
+ mac = hmac.new(force_bytes(obj.webhook_key), msg=force_bytes(self.request.read()), digestmod=sha1)
if not hmac.compare_digest(force_bytes(mac.hexdigest()), self.get_signature()):
raise PermissionDenied
+ @csrf_exempt
def post(self, request, *args, **kwargs):
+ logger.error(
+ "**************************************\n"
+ "{}\n"
+ "{}\n".format(request.headers, request.data)
+ )
obj = self.get_object()
self.check_signature(obj)
+ return Response(status=status.HTTP_202_ACCEPTED)
+
class GithubWebhookReceiver(WebhookReceiverBase):
service = 'github'
From 992c4147371c80d23da0fd59682fa32355ef5946 Mon Sep 17 00:00:00 2001
From: Jeff Bradberry
Date: Wed, 28 Aug 2019 16:57:53 -0400
Subject: [PATCH 19/74] Launch a Job or WorkflowJob based on the incoming
webhook
---
awx/api/views/webhooks.py | 13 +++++++++++--
1 file changed, 11 insertions(+), 2 deletions(-)
diff --git a/awx/api/views/webhooks.py b/awx/api/views/webhooks.py
index 45b92b63c1..d72fc098b2 100644
--- a/awx/api/views/webhooks.py
+++ b/awx/api/views/webhooks.py
@@ -1,5 +1,6 @@
from hashlib import sha1
import hmac
+import json
import logging
from django.utils.encoding import force_bytes
@@ -60,7 +61,7 @@ class WebhookReceiverBase(APIView):
if model is None:
raise PermissionDenied
- return model.objects.filter(webhook_service=self.service)
+ return model.objects.filter(webhook_service=self.service).exclude(webhook_key='')
def get_object(self):
queryset = self.get_queryset()
@@ -100,6 +101,14 @@ class WebhookReceiverBase(APIView):
obj = self.get_object()
self.check_signature(obj)
+ data = {
+ 'tower_webhook_event_type': self.get_event_type(),
+ 'tower_webhook_event_guid': self.get_event_guid(),
+ 'tower_webhook_payload': request.data,
+ }
+ new_job = obj.create_unified_job(extra_vars=json.dumps(data))
+ new_job.signal_start()
+
return Response(status=status.HTTP_202_ACCEPTED)
@@ -133,7 +142,7 @@ class GitlabWebhookReceiver(WebhookReceiverBase):
return ''
def get_signature(self):
- return self.request.META.get('HTTP_X_GITLAB_TOKEN')
+ return force_bytes(self.request.META.get('HTTP_X_GITLAB_TOKEN'))
def check_signature(self, obj):
if not obj.webhook_key:
From 8836ed44ce711708cdfcb31ea8668a789cc4cc0e Mon Sep 17 00:00:00 2001
From: Jeff Bradberry
Date: Thu, 29 Aug 2019 11:38:24 -0400
Subject: [PATCH 20/74] Construct an ID for Gitlab webhooks
by taking the SHA1 of the body of the webhook request.
---
awx/api/views/webhooks.py | 6 ++++--
1 file changed, 4 insertions(+), 2 deletions(-)
diff --git a/awx/api/views/webhooks.py b/awx/api/views/webhooks.py
index d72fc098b2..65635b033a 100644
--- a/awx/api/views/webhooks.py
+++ b/awx/api/views/webhooks.py
@@ -138,8 +138,10 @@ class GitlabWebhookReceiver(WebhookReceiverBase):
return self.request.META.get('HTTP_X_GITLAB_EVENT')
def get_event_guid(self):
- # Gitlab does not provide a unique identifier on events.
- return ''
+ # Gitlab does not provide a unique identifier on events, so construct one.
+ h = sha1()
+ h.update(force_bytes(self.request.read()))
+ return h.hexdigest()
def get_signature(self):
return force_bytes(self.request.META.get('HTTP_X_GITLAB_TOKEN'))
From 4dba9916dc9ce3ab9e9aa67332de0ee722442843 Mon Sep 17 00:00:00 2001
From: Jeff Bradberry
Date: Tue, 3 Sep 2019 13:57:18 -0400
Subject: [PATCH 21/74] Add a new set of personal access token credential types
---
.../0093_v360_personal_access_tokens.py | 27 ++++++++++
awx/main/models/credential/__init__.py | 49 +++++++++++++++++++
2 files changed, 76 insertions(+)
create mode 100644 awx/main/migrations/0093_v360_personal_access_tokens.py
diff --git a/awx/main/migrations/0093_v360_personal_access_tokens.py b/awx/main/migrations/0093_v360_personal_access_tokens.py
new file mode 100644
index 0000000000..1dd1bbc094
--- /dev/null
+++ b/awx/main/migrations/0093_v360_personal_access_tokens.py
@@ -0,0 +1,27 @@
+# Generated by Django 2.2.4 on 2019-09-12 14:50
+
+from django.db import migrations, models
+
+from awx.main.models import CredentialType
+from awx.main.utils.common import set_current_apps
+
+
+def setup_tower_managed_defaults(apps, schema_editor):
+ set_current_apps(apps)
+ CredentialType.setup_tower_managed_defaults()
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('main', '0092_v360_webhook_mixin'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='credentialtype',
+ name='kind',
+ field=models.CharField(choices=[('ssh', 'Machine'), ('vault', 'Vault'), ('net', 'Network'), ('scm', 'Source Control'), ('cloud', 'Cloud'), ('token', 'Personal Access Token'), ('insights', 'Insights'), ('external', 'External')], max_length=32),
+ ),
+ migrations.RunPython(setup_tower_managed_defaults),
+ ]
diff --git a/awx/main/models/credential/__init__.py b/awx/main/models/credential/__init__.py
index 07bfe645d8..0091582252 100644
--- a/awx/main/models/credential/__init__.py
+++ b/awx/main/models/credential/__init__.py
@@ -322,6 +322,7 @@ class CredentialType(CommonModelNameNotUnique):
('net', _('Network')),
('scm', _('Source Control')),
('cloud', _('Cloud')),
+ ('token', _('Personal Access Token')),
('insights', _('Insights')),
('external', _('External')),
)
@@ -968,6 +969,54 @@ ManagedCredentialType(
}
)
+ManagedCredentialType(
+ namespace='github_token',
+ kind='token',
+ name=ugettext_noop('Github Personal Access Token'),
+ managed_by_tower=True,
+ inputs={
+ 'fields': [{
+ 'id': 'token',
+ 'label': ugettext_noop('Token'),
+ 'type': 'string',
+ 'secret': True,
+ }],
+ 'required': ['token'],
+ },
+)
+
+ManagedCredentialType(
+ namespace='gitlab_token',
+ kind='token',
+ name=ugettext_noop('Gitlab Personal Access Token'),
+ managed_by_tower=True,
+ inputs={
+ 'fields': [{
+ 'id': 'token',
+ 'label': ugettext_noop('Token'),
+ 'type': 'string',
+ 'secret': True,
+ }],
+ 'required': ['token'],
+ },
+)
+
+ManagedCredentialType(
+ namespace='bitbucket_token',
+ kind='token',
+ name=ugettext_noop('Bitbucket Personal Access Token'),
+ managed_by_tower=True,
+ inputs={
+ 'fields': [{
+ 'id': 'token',
+ 'label': ugettext_noop('Token'),
+ 'type': 'string',
+ 'secret': True,
+ }],
+ 'required': ['token'],
+ },
+)
+
ManagedCredentialType(
namespace='insights',
kind='insights',
From 83fc2187ccd2f2474def34b3aa249beb3984b78d Mon Sep 17 00:00:00 2001
From: Jeff Bradberry
Date: Tue, 3 Sep 2019 14:27:36 -0400
Subject: [PATCH 22/74] Fix the summary fields for webhook_credential
---
awx/api/serializers.py | 5 +++--
1 file changed, 3 insertions(+), 2 deletions(-)
diff --git a/awx/api/serializers.py b/awx/api/serializers.py
index c87b596782..8fe9b677d4 100644
--- a/awx/api/serializers.py
+++ b/awx/api/serializers.py
@@ -118,8 +118,8 @@ SUMMARIZABLE_FK_FIELDS = {
'project_update': DEFAULT_SUMMARY_FIELDS + ('status', 'failed',),
'credential': DEFAULT_SUMMARY_FIELDS + ('kind', 'cloud', 'credential_type_id'),
'job': DEFAULT_SUMMARY_FIELDS + ('status', 'failed', 'elapsed', 'type'),
- 'job_template': DEFAULT_SUMMARY_FIELDS + ('webhook_credential',),
- 'workflow_job_template': DEFAULT_SUMMARY_FIELDS + ('webhook_credential',),
+ 'job_template': DEFAULT_SUMMARY_FIELDS,
+ 'workflow_job_template': DEFAULT_SUMMARY_FIELDS,
'workflow_job': DEFAULT_SUMMARY_FIELDS,
'workflow_approval_template': DEFAULT_SUMMARY_FIELDS + ('timeout',),
'workflow_approval': DEFAULT_SUMMARY_FIELDS + ('timeout',),
@@ -139,6 +139,7 @@ SUMMARIZABLE_FK_FIELDS = {
'insights_credential': DEFAULT_SUMMARY_FIELDS,
'source_credential': DEFAULT_SUMMARY_FIELDS + ('kind', 'cloud', 'credential_type_id'),
'target_credential': DEFAULT_SUMMARY_FIELDS + ('kind', 'cloud', 'credential_type_id'),
+ 'webhook_credential': DEFAULT_SUMMARY_FIELDS,
}
From 5848f0360ab19f2fe88fc709d45b7cd227bfd095 Mon Sep 17 00:00:00 2001
From: Jeff Bradberry
Date: Tue, 3 Sep 2019 14:36:18 -0400
Subject: [PATCH 23/74] Update test_default_cred_types to include the new
personal access token types
---
awx/main/tests/functional/test_credential.py | 3 +++
1 file changed, 3 insertions(+)
diff --git a/awx/main/tests/functional/test_credential.py b/awx/main/tests/functional/test_credential.py
index c95817199a..c241ea002b 100644
--- a/awx/main/tests/functional/test_credential.py
+++ b/awx/main/tests/functional/test_credential.py
@@ -79,9 +79,12 @@ def test_default_cred_types():
'aws',
'azure_kv',
'azure_rm',
+ 'bitbucket_token',
'cloudforms',
'conjur',
'gce',
+ 'github_token',
+ 'gitlab_token',
'hashivault_kv',
'hashivault_ssh',
'insights',
From bb1397a3d41fa2833bbbf4a0950bdb60f5857c49 Mon Sep 17 00:00:00 2001
From: Jeff Bradberry
Date: Wed, 4 Sep 2019 14:13:25 -0400
Subject: [PATCH 24/74] Validate the webhook credential
- we should allow a null credential, so that the admin can choose to configure not posting back status changes of the triggered job
- the credential must be of the new 'token' kind
- if we do configure a credential, its type must match the selected SCM service
---
awx/api/serializers.py | 18 ++++++-
.../tests/functional/api/test_webhooks.py | 54 +++++++++++++++++++
2 files changed, 71 insertions(+), 1 deletion(-)
diff --git a/awx/api/serializers.py b/awx/api/serializers.py
index 8fe9b677d4..3a54012d62 100644
--- a/awx/api/serializers.py
+++ b/awx/api/serializers.py
@@ -2827,6 +2827,23 @@ class JobTemplateMixin(object):
d['recent_jobs'] = self._recent_jobs(obj)
return d
+ def validate(self, attrs):
+ webhook_service = attrs.get('webhook_service', getattr(self.instance, 'webhook_service', None))
+ webhook_credential = attrs.get('webhook_credential', getattr(self.instance, 'webhook_credential', None))
+
+ if webhook_credential and webhook_credential.credential_type.kind != 'token':
+ raise serializers.ValidationError({
+ 'webhook_credential': _("Must be a Personal Access Token."),
+ })
+
+ if webhook_service and webhook_credential:
+ if webhook_credential.kind != '{}_token'.format(webhook_service):
+ raise serializers.ValidationError({
+ 'webhook_credential': _("Must match the selected webhook service."),
+ })
+
+ return super().validate(attrs)
+
class JobTemplateSerializer(JobTemplateMixin, UnifiedJobTemplateSerializer, JobOptionsSerializer):
show_capabilities = ['start', 'schedule', 'copy', 'edit', 'delete']
@@ -2894,7 +2911,6 @@ class JobTemplateSerializer(JobTemplateMixin, UnifiedJobTemplateSerializer, JobO
def validate_extra_vars(self, value):
return vars_validate_or_raise(value)
-
def get_summary_fields(self, obj):
summary_fields = super(JobTemplateSerializer, self).get_summary_fields(obj)
all_creds = []
diff --git a/awx/main/tests/functional/api/test_webhooks.py b/awx/main/tests/functional/api/test_webhooks.py
index 3e48906fc7..bbc3e353c9 100644
--- a/awx/main/tests/functional/api/test_webhooks.py
+++ b/awx/main/tests/functional/api/test_webhooks.py
@@ -2,6 +2,7 @@ import pytest
from awx.api.versioning import reverse
from awx.main.models.mixins import WebhookMixin
+from awx.main.models.credential import Credential, CredentialType
@pytest.mark.django_db
@@ -146,3 +147,56 @@ def test_unset_webhook_service(organization_factory, job_template_factory, patch
jt.refresh_from_db()
assert (jt.webhook_service, jt.webhook_key) == ('', '')
+
+
+@pytest.mark.django_db
+@pytest.mark.parametrize(
+ "service", [s for s, _ in WebhookMixin.SERVICES]
+)
+def test_set_webhook_credential(organization_factory, job_template_factory, patch, service):
+ objs = organization_factory("org", superusers=['admin'])
+ jt = job_template_factory("jt", organization=objs.organization, webhook_service=service,
+ inventory='test_inv', project='test_proj').job_template
+ admin = objs.superusers.admin
+ assert jt.webhook_service == service
+ assert jt.webhook_key != ''
+
+ cred_type = CredentialType.defaults['{}_token'.format(service)]()
+ cred_type.save()
+ cred = Credential.objects.create(credential_type=cred_type, name='test-cred',
+ inputs={'token': 'secret'})
+
+ url = reverse('api:job_template_detail', kwargs={'pk': jt.pk})
+ patch(url, {'webhook_credential': cred.pk}, user=admin, expect=200)
+ jt.refresh_from_db()
+
+ assert jt.webhook_service == service
+ assert jt.webhook_key != ''
+ assert jt.webhook_credential == cred
+
+
+@pytest.mark.django_db
+@pytest.mark.parametrize(
+ "service,token", [(s, WebhookMixin.SERVICES[i - 1][0]) for i, (s, _) in enumerate(WebhookMixin.SERVICES)]
+)
+def test_set_wrong_service_webhook_credential(organization_factory, job_template_factory, patch, service, token):
+ objs = organization_factory("org", superusers=['admin'])
+ jt = job_template_factory("jt", organization=objs.organization, webhook_service=service,
+ inventory='test_inv', project='test_proj').job_template
+ admin = objs.superusers.admin
+ assert jt.webhook_service == service
+ assert jt.webhook_key != ''
+
+ cred_type = CredentialType.defaults['{}_token'.format(token)]()
+ cred_type.save()
+ cred = Credential.objects.create(credential_type=cred_type, name='test-cred',
+ inputs={'token': 'secret'})
+
+ url = reverse('api:job_template_detail', kwargs={'pk': jt.pk})
+ response = patch(url, {'webhook_credential': cred.pk}, user=admin, expect=400)
+ jt.refresh_from_db()
+
+ assert jt.webhook_service == service
+ assert jt.webhook_key != ''
+ assert jt.webhook_credential is None
+ assert response.data == {'webhook_credential': ["Must match the selected webhook service."]}
From 095aa77857e043dcc72148f545640aaa55e34104 Mon Sep 17 00:00:00 2001
From: Jeff Bradberry
Date: Wed, 4 Sep 2019 15:41:59 -0400
Subject: [PATCH 25/74] Create a new model mixin for Job and WorkflowJob
webhook fields
---
.../migrations/0094_v360_webhook_mixin2.py | 44 +++++++++++++++++++
awx/main/models/jobs.py | 5 ++-
awx/main/models/mixins.py | 26 ++++++++++-
awx/main/models/workflow.py | 5 ++-
.../tests/functional/api/test_webhooks.py | 12 ++---
5 files changed, 82 insertions(+), 10 deletions(-)
create mode 100644 awx/main/migrations/0094_v360_webhook_mixin2.py
diff --git a/awx/main/migrations/0094_v360_webhook_mixin2.py b/awx/main/migrations/0094_v360_webhook_mixin2.py
new file mode 100644
index 0000000000..ff4eb7abc1
--- /dev/null
+++ b/awx/main/migrations/0094_v360_webhook_mixin2.py
@@ -0,0 +1,44 @@
+# Generated by Django 2.2.4 on 2019-09-12 14:52
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('main', '0093_v360_personal_access_tokens'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='job',
+ name='webhook_credential',
+ field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='jobs', to='main.Credential'),
+ ),
+ migrations.AddField(
+ model_name='job',
+ name='webhook_guid',
+ field=models.CharField(blank=True, max_length=128),
+ ),
+ migrations.AddField(
+ model_name='job',
+ name='webhook_service',
+ field=models.CharField(blank=True, choices=[('github', 'Github'), ('gitlab', 'Gitlab'), ('bitbucket', 'Bitbucket')], max_length=16),
+ ),
+ migrations.AddField(
+ model_name='workflowjob',
+ name='webhook_credential',
+ field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='workflowjobs', to='main.Credential'),
+ ),
+ migrations.AddField(
+ model_name='workflowjob',
+ name='webhook_guid',
+ field=models.CharField(blank=True, max_length=128),
+ ),
+ migrations.AddField(
+ model_name='workflowjob',
+ name='webhook_service',
+ field=models.CharField(blank=True, choices=[('github', 'Github'), ('gitlab', 'Gitlab'), ('bitbucket', 'Bitbucket')], max_length=16),
+ ),
+ ]
diff --git a/awx/main/models/jobs.py b/awx/main/models/jobs.py
index 97fae67511..40f60c7705 100644
--- a/awx/main/models/jobs.py
+++ b/awx/main/models/jobs.py
@@ -49,6 +49,7 @@ from awx.main.models.mixins import (
CustomVirtualEnvMixin,
RelatedJobsMixin,
WebhookMixin,
+ WebhookTemplateMixin,
)
@@ -188,7 +189,7 @@ class JobOptions(BaseModel):
return needed
-class JobTemplate(UnifiedJobTemplate, JobOptions, SurveyJobTemplateMixin, ResourceMixin, CustomVirtualEnvMixin, RelatedJobsMixin, WebhookMixin):
+class JobTemplate(UnifiedJobTemplate, JobOptions, SurveyJobTemplateMixin, ResourceMixin, CustomVirtualEnvMixin, RelatedJobsMixin, WebhookTemplateMixin):
'''
A job template is a reusable job definition for applying a project (with
playbook) to an inventory source with a given credential.
@@ -485,7 +486,7 @@ class JobTemplate(UnifiedJobTemplate, JobOptions, SurveyJobTemplateMixin, Resour
return UnifiedJob.objects.filter(unified_job_template=self)
-class Job(UnifiedJob, JobOptions, SurveyJobMixin, JobNotificationMixin, TaskManagerJobMixin, CustomVirtualEnvMixin):
+class Job(UnifiedJob, JobOptions, SurveyJobMixin, JobNotificationMixin, TaskManagerJobMixin, CustomVirtualEnvMixin, WebhookMixin):
'''
A job applies a project (with playbook) to an inventory source with a given
credential. It represents a single invocation of ansible-playbook with the
diff --git a/awx/main/models/mixins.py b/awx/main/models/mixins.py
index 2ab8fc786e..dddca05d6e 100644
--- a/awx/main/models/mixins.py
+++ b/awx/main/models/mixins.py
@@ -486,7 +486,7 @@ class RelatedJobsMixin(object):
return [dict(id=t[0], type=mapping[t[1]]) for t in jobs.values_list('id', 'polymorphic_ctype_id')]
-class WebhookMixin(models.Model):
+class WebhookTemplateMixin(models.Model):
class Meta:
abstract = True
@@ -529,3 +529,27 @@ class WebhookMixin(models.Model):
update_fields.append('webhook_key')
super().save(*args, **kwargs)
+
+
+class WebhookMixin(models.Model):
+ class Meta:
+ abstract = True
+
+ SERVICES = WebhookTemplateMixin.SERVICES
+
+ webhook_service = models.CharField(
+ max_length=16,
+ choices=SERVICES,
+ blank=True
+ )
+ webhook_credential = models.ForeignKey(
+ 'Credential',
+ blank=True,
+ null=True,
+ on_delete=models.SET_NULL,
+ related_name='%(class)ss'
+ )
+ webhook_guid = models.CharField(
+ blank=True,
+ max_length=128
+ )
diff --git a/awx/main/models/workflow.py b/awx/main/models/workflow.py
index cc3133740e..e85e531aaf 100644
--- a/awx/main/models/workflow.py
+++ b/awx/main/models/workflow.py
@@ -33,6 +33,7 @@ from awx.main.models.mixins import (
SurveyJobMixin,
RelatedJobsMixin,
WebhookMixin,
+ WebhookTemplateMixin,
)
from awx.main.models.jobs import LaunchTimeConfigBase, LaunchTimeConfig, JobTemplate
from awx.main.models.credential import Credential
@@ -359,7 +360,7 @@ class WorkflowJobOptions(LaunchTimeConfigBase):
return new_workflow_job
-class WorkflowJobTemplate(UnifiedJobTemplate, WorkflowJobOptions, SurveyJobTemplateMixin, ResourceMixin, RelatedJobsMixin, WebhookMixin):
+class WorkflowJobTemplate(UnifiedJobTemplate, WorkflowJobOptions, SurveyJobTemplateMixin, ResourceMixin, RelatedJobsMixin, WebhookTemplateMixin):
SOFT_UNIQUE_TOGETHER = [('polymorphic_ctype', 'name', 'organization')]
FIELDS_TO_PRESERVE_AT_COPY = [
@@ -531,7 +532,7 @@ class WorkflowJobTemplate(UnifiedJobTemplate, WorkflowJobOptions, SurveyJobTempl
return WorkflowJob.objects.filter(workflow_job_template=self)
-class WorkflowJob(UnifiedJob, WorkflowJobOptions, SurveyJobMixin, JobNotificationMixin):
+class WorkflowJob(UnifiedJob, WorkflowJobOptions, SurveyJobMixin, JobNotificationMixin, WebhookMixin):
class Meta:
app_label = 'main'
ordering = ('id',)
diff --git a/awx/main/tests/functional/api/test_webhooks.py b/awx/main/tests/functional/api/test_webhooks.py
index bbc3e353c9..20be437b89 100644
--- a/awx/main/tests/functional/api/test_webhooks.py
+++ b/awx/main/tests/functional/api/test_webhooks.py
@@ -1,7 +1,7 @@
import pytest
from awx.api.versioning import reverse
-from awx.main.models.mixins import WebhookMixin
+from awx.main.models.mixins import WebhookTemplateMixin
from awx.main.models.credential import Credential, CredentialType
@@ -113,7 +113,7 @@ def test_post_webhook_key_wfjt(organization_factory, workflow_job_template_facto
@pytest.mark.django_db
@pytest.mark.parametrize(
- "service", [s for s, _ in WebhookMixin.SERVICES]
+ "service", [s for s, _ in WebhookTemplateMixin.SERVICES]
)
def test_set_webhook_service(organization_factory, job_template_factory, patch, service):
objs = organization_factory("org", superusers=['admin'])
@@ -132,7 +132,7 @@ def test_set_webhook_service(organization_factory, job_template_factory, patch,
@pytest.mark.django_db
@pytest.mark.parametrize(
- "service", [s for s, _ in WebhookMixin.SERVICES]
+ "service", [s for s, _ in WebhookTemplateMixin.SERVICES]
)
def test_unset_webhook_service(organization_factory, job_template_factory, patch, service):
objs = organization_factory("org", superusers=['admin'])
@@ -151,7 +151,7 @@ def test_unset_webhook_service(organization_factory, job_template_factory, patch
@pytest.mark.django_db
@pytest.mark.parametrize(
- "service", [s for s, _ in WebhookMixin.SERVICES]
+ "service", [s for s, _ in WebhookTemplateMixin.SERVICES]
)
def test_set_webhook_credential(organization_factory, job_template_factory, patch, service):
objs = organization_factory("org", superusers=['admin'])
@@ -177,7 +177,9 @@ def test_set_webhook_credential(organization_factory, job_template_factory, patc
@pytest.mark.django_db
@pytest.mark.parametrize(
- "service,token", [(s, WebhookMixin.SERVICES[i - 1][0]) for i, (s, _) in enumerate(WebhookMixin.SERVICES)]
+ "service,token", [
+ (s, WebhookTemplateMixin.SERVICES[i - 1][0]) for i, (s, _) in enumerate(WebhookTemplateMixin.SERVICES)
+ ]
)
def test_set_wrong_service_webhook_credential(organization_factory, job_template_factory, patch, service, token):
objs = organization_factory("org", superusers=['admin'])
From 245931f6033e9d9042c72b05b0f0e34ba12896a3 Mon Sep 17 00:00:00 2001
From: Jeff Bradberry
Date: Wed, 4 Sep 2019 16:47:30 -0400
Subject: [PATCH 26/74] Debounce when multiple copies of the same webhook event
come in
---
awx/api/views/webhooks.py | 32 ++++++++++++++++++++++++--------
1 file changed, 24 insertions(+), 8 deletions(-)
diff --git a/awx/api/views/webhooks.py b/awx/api/views/webhooks.py
index 65635b033a..31b9755575 100644
--- a/awx/api/views/webhooks.py
+++ b/awx/api/views/webhooks.py
@@ -14,7 +14,7 @@ from rest_framework.response import Response
from awx.api import serializers
from awx.api.generics import APIView, GenericAPIView
from awx.api.permissions import WebhookKeyPermission
-from awx.main.models import JobTemplate, WorkflowJobTemplate
+from awx.main.models import Job, JobTemplate, WorkflowJob, WorkflowJobTemplate
logger = logging.getLogger('awx.api.views.webhooks')
@@ -93,20 +93,36 @@ class WebhookReceiverBase(APIView):
@csrf_exempt
def post(self, request, *args, **kwargs):
- logger.error(
- "**************************************\n"
- "{}\n"
- "{}\n".format(request.headers, request.data)
+ logger.debug(
+ "headers: {}\n"
+ "data: {}\n".format(request.headers, request.data)
)
obj = self.get_object()
self.check_signature(obj)
+ event_type = self.get_event_type()
+ event_guid = self.get_event_guid()
+
+ kwargs = {
+ 'webhook_service': obj.webhook_service,
+ 'webhook_guid': event_guid,
+ }
+ if WorkflowJob.objects.filter(**kwargs).exists() or Job.objects.filter(**kwargs).exists():
+ # Short circuit if this webhook has already been received and acted upon.
+ logger.debug("Webhook previously received, returning without action.")
+ return Response(status=status.HTTP_202_ACCEPTED)
+
data = {
- 'tower_webhook_event_type': self.get_event_type(),
- 'tower_webhook_event_guid': self.get_event_guid(),
+ 'tower_webhook_event_type': event_type,
+ 'tower_webhook_event_guid': event_guid,
'tower_webhook_payload': request.data,
}
- new_job = obj.create_unified_job(extra_vars=json.dumps(data))
+ new_job = obj.create_unified_job(
+ webhook_service=obj.webhook_service,
+ webhook_credential=obj.webhook_credential,
+ webhook_guid=event_guid,
+ extra_vars=json.dumps(data)
+ )
new_job.signal_start()
return Response(status=status.HTTP_202_ACCEPTED)
From ee1d118752892a629d3a8cb376c1508091b78dde Mon Sep 17 00:00:00 2001
From: Jeff Bradberry
Date: Thu, 5 Sep 2019 14:24:46 -0400
Subject: [PATCH 27/74] Add the webhook receiver url to the related urls in the
serializers
---
awx/api/serializers.py | 10 ++++++++++
.../api/serializers/test_job_template_serializers.py | 1 +
2 files changed, 11 insertions(+)
diff --git a/awx/api/serializers.py b/awx/api/serializers.py
index 3a54012d62..cd81f7107f 100644
--- a/awx/api/serializers.py
+++ b/awx/api/serializers.py
@@ -2873,6 +2873,11 @@ class JobTemplateSerializer(JobTemplateMixin, UnifiedJobTemplateSerializer, JobO
activity_stream=self.reverse('api:job_template_activity_stream_list', kwargs={'pk': obj.pk}),
launch=self.reverse('api:job_template_launch', kwargs={'pk': obj.pk}),
webhook_key=self.reverse('api:webhook_key', kwargs={'model_kwarg': 'job_templates', 'pk': obj.pk}),
+ webhook_receiver=(
+ self.reverse('api:webhook_receiver_{}'.format(obj.webhook_service),
+ kwargs={'model_kwarg': 'job_templates', 'pk': obj.pk})
+ if obj.webhook_service else ''
+ ),
notification_templates_started=self.reverse('api:job_template_notification_templates_started_list', kwargs={'pk': obj.pk}),
notification_templates_success=self.reverse('api:job_template_notification_templates_success_list', kwargs={'pk': obj.pk}),
notification_templates_error=self.reverse('api:job_template_notification_templates_error_list', kwargs={'pk': obj.pk}),
@@ -3355,6 +3360,11 @@ class WorkflowJobTemplateSerializer(JobTemplateMixin, LabelsListMixin, UnifiedJo
schedules = self.reverse('api:workflow_job_template_schedules_list', kwargs={'pk': obj.pk}),
launch = self.reverse('api:workflow_job_template_launch', kwargs={'pk': obj.pk}),
webhook_key=self.reverse('api:webhook_key', kwargs={'model_kwarg': 'workflow_job_templates', 'pk': obj.pk}),
+ webhook_receiver=(
+ self.reverse('api:webhook_receiver_{}'.format(obj.webhook_service),
+ kwargs={'model_kwarg': 'job_templates', 'pk': obj.pk})
+ if obj.webhook_service else ''
+ ),
workflow_nodes = self.reverse('api:workflow_job_template_workflow_nodes_list', kwargs={'pk': obj.pk}),
labels = self.reverse('api:workflow_job_template_label_list', kwargs={'pk': obj.pk}),
activity_stream = self.reverse('api:workflow_job_template_activity_stream_list', kwargs={'pk': obj.pk}),
diff --git a/awx/main/tests/unit/api/serializers/test_job_template_serializers.py b/awx/main/tests/unit/api/serializers/test_job_template_serializers.py
index 730d74229b..4c0751ffbe 100644
--- a/awx/main/tests/unit/api/serializers/test_job_template_serializers.py
+++ b/awx/main/tests/unit/api/serializers/test_job_template_serializers.py
@@ -29,6 +29,7 @@ def job_template(mocker):
mock_jt.pk = 5
mock_jt.host_config_key = '9283920492'
mock_jt.validation_errors = mock_JT_resource_data
+ mock_jt.webhook_service = ''
return mock_jt
From 17b34b1e36538b7d19323d7dc6db5faf115b4e75 Mon Sep 17 00:00:00 2001
From: Jake McDermott
Date: Wed, 4 Sep 2019 17:43:02 -0400
Subject: [PATCH 28/74] add webhook service field
---
.../job-template-add.controller.js | 17 +++++++++-
.../job-template-edit.controller.js | 31 +++++++++++++++++++
.../job_templates/job-template.form.js | 15 +++++++++
3 files changed, 62 insertions(+), 1 deletion(-)
diff --git a/awx/ui/client/src/templates/job_templates/add-job-template/job-template-add.controller.js b/awx/ui/client/src/templates/job_templates/add-job-template/job-template-add.controller.js
index bb3891278f..561d7581f1 100644
--- a/awx/ui/client/src/templates/job_templates/add-job-template/job-template-add.controller.js
+++ b/awx/ui/client/src/templates/job_templates/add-job-template/job-template-add.controller.js
@@ -39,6 +39,7 @@
$scope.can_edit = true;
$scope.allow_callbacks = false;
$scope.playbook_options = [];
+ $scope.webhook_service_options = [];
$scope.mode = "add";
$scope.parseType = 'yaml';
$scope.credentialNotPresent = false;
@@ -131,6 +132,14 @@
multiple: false,
opts: $scope.custom_virtualenvs_options
});
+ CreateSelect2({
+ element:'#webhook-service-select',
+ addNew: false,
+ multiple: false,
+ scope: $scope,
+ options: 'webhook_service_options',
+ model: 'webhook_service'
+ });
}
});
@@ -151,7 +160,13 @@
variable: 'job_type_options',
callback: 'choicesReadyVerbosity'
});
-
+ GetChoices({
+ scope: $scope,
+ url: defaultUrl,
+ field: 'webhook_service',
+ variable: 'webhook_service_options',
+ callback: 'choicesReadyVerbosity'
+ });
$scope.labelOptions = availableLabels
.map((i) => ({label: i.name, value: i.id}));
$scope.$emit("choicesReadyVerbosity");
diff --git a/awx/ui/client/src/templates/job_templates/edit-job-template/job-template-edit.controller.js b/awx/ui/client/src/templates/job_templates/edit-job-template/job-template-edit.controller.js
index 4512dfd622..3d76ebd3ee 100644
--- a/awx/ui/client/src/templates/job_templates/edit-job-template/job-template-edit.controller.js
+++ b/awx/ui/client/src/templates/job_templates/edit-job-template/job-template-edit.controller.js
@@ -61,7 +61,9 @@ export default
$scope.sufficientRoleForNotifToggle = isNotificationAdmin;
$scope.sufficientRoleForNotif = isNotificationAdmin || $scope.user_is_system_auditor;
$scope.playbook_options = null;
+ $scope.webhook_service_options = null;
$scope.playbook = null;
+ $scope.webhook_service = null;
$scope.mode = 'edit';
$scope.parseType = 'yaml';
$scope.showJobType = false;
@@ -177,6 +179,7 @@ export default
// watch for changes to 'verbosity', ensure we keep our select2 in sync when it changes.
$scope.$watch('verbosity', sync_verbosity_select2);
+ $scope.$watch('webhook_service', sync_webhook_service_select2);
}
callback = function() {
@@ -202,7 +205,19 @@ export default
}));
}
+ function sync_webhook_service_select2() {
+ select2LoadDefer.push(CreateSelect2({
+ element:'#webhook-service-select',
+ addNew: false,
+ multiple: false,
+ scope: $scope,
+ options: 'webhook_service_options',
+ model: 'webhook_service'
+ }));
+ }
+
function jobTemplateLoadFinished(){
+ //$scope.webhook_service = jobTemplateData.webhook_service;
select2LoadDefer.push(CreateSelect2({
element:'#job_template_job_type',
multiple: false
@@ -225,6 +240,14 @@ export default
multiple: false,
opts: $scope.custom_virtualenvs_options
}));
+ select2LoadDefer.push(CreateSelect2({
+ element:'#webhook-service-select',
+ addNew: false,
+ multiple: false,
+ scope: $scope,
+ options: 'webhook_service_options',
+ model: 'webhook_service'
+ }));
if (!launchHasBeenEnabled) {
$q.all(select2LoadDefer).then(() => {
@@ -498,6 +521,14 @@ export default
callback: 'choicesReady'
});
+ GetChoices({
+ scope: $scope,
+ url: defaultUrl,
+ field: 'webhook_service',
+ variable: 'webhook_service_options',
+ callback: 'choicesReady'
+ });
+
$scope.labelOptions = availableLabels
.map((i) => ({label: i.name, value: i.id}));
diff --git a/awx/ui/client/src/templates/job_templates/job-template.form.js b/awx/ui/client/src/templates/job_templates/job-template.form.js
index 83b2d953eb..fb29d0b1fd 100644
--- a/awx/ui/client/src/templates/job_templates/job-template.form.js
+++ b/awx/ui/client/src/templates/job_templates/job-template.form.js
@@ -391,6 +391,21 @@ function(NotificationsList, i18n) {
alwaysShowAsterisk: true
}
},
+ webhook_service: {
+ label: i18n._('Webhook Service'),
+ type:'select',
+ defaultText: i18n._('Choose a Webhook Service'),
+ ngOptions: 'svc.label for svc in webhook_service_options track by svc.value',
+ ngShow: true,
+ ngDisabled: "!(job_template_obj.summary_fields.user_capabilities.edit || canAddJobTemplate) || !canGetAllRelatedResources",
+ id: 'webhook-service-select',
+ required: false,
+ column: 1,
+ awPopOver: "" + i18n._("Select a webhook service.") + "
",
+ dataTitle: i18n._('Webhook Service'),
+ dataPlacement: 'right',
+ dataContainer: "body",
+ },
extra_vars: {
label: i18n._('Extra Variables'),
type: 'textarea',
From f5c151d5c468d3db9e501e6b7ef395faedf4d584 Mon Sep 17 00:00:00 2001
From: Jake McDermott
Date: Wed, 4 Sep 2019 18:25:35 -0400
Subject: [PATCH 29/74] add webhook url field
---
.../job-template-add.controller.js | 1 +
.../job-template-edit.controller.js | 19 ++++++++++++++++++-
.../job_templates/job-template.form.js | 13 +++++++++++++
3 files changed, 32 insertions(+), 1 deletion(-)
diff --git a/awx/ui/client/src/templates/job_templates/add-job-template/job-template-add.controller.js b/awx/ui/client/src/templates/job_templates/add-job-template/job-template-add.controller.js
index 561d7581f1..218f3353f7 100644
--- a/awx/ui/client/src/templates/job_templates/add-job-template/job-template-add.controller.js
+++ b/awx/ui/client/src/templates/job_templates/add-job-template/job-template-add.controller.js
@@ -343,6 +343,7 @@
// be provided to the related credentials endpoint by the template save success handler.
delete data.credential;
delete data.vault_credential;
+ delete data.webhook_url;
data.extra_vars = ToJSON($scope.parseType, $scope.extra_vars, true);
diff --git a/awx/ui/client/src/templates/job_templates/edit-job-template/job-template-edit.controller.js b/awx/ui/client/src/templates/job_templates/edit-job-template/job-template-edit.controller.js
index 3d76ebd3ee..2c2a20ecb3 100644
--- a/awx/ui/client/src/templates/job_templates/edit-job-template/job-template-edit.controller.js
+++ b/awx/ui/client/src/templates/job_templates/edit-job-template/job-template-edit.controller.js
@@ -64,6 +64,7 @@ export default
$scope.webhook_service_options = null;
$scope.playbook = null;
$scope.webhook_service = null;
+ $scope.webhook_url = '';
$scope.mode = 'edit';
$scope.parseType = 'yaml';
$scope.showJobType = false;
@@ -74,6 +75,7 @@ export default
$scope.skip_tag_options = [];
const virtualEnvs = ConfigData.custom_virtualenvs || [];
$scope.custom_virtualenvs_options = virtualEnvs;
+ $scope.webhook_url_help = i18n._('Webhook services can launch jobs with this job template by making a POST request to this URL.');
SurveyControllerInit({
scope: $scope,
@@ -179,7 +181,21 @@ export default
// watch for changes to 'verbosity', ensure we keep our select2 in sync when it changes.
$scope.$watch('verbosity', sync_verbosity_select2);
- $scope.$watch('webhook_service', sync_webhook_service_select2);
+ $scope.$watch('webhook_service', (newValue) => {
+ if (newValue) {
+ // TODO: We'll need the host from the server.
+ const baseURL = window.location.origin;
+ if (typeof newValue === 'string') {
+ $scope.webhook_url = `${baseURL}${jobTemplateData.url}${newValue}`;
+ $scope.webhook_service = { value: newValue };
+ } else {
+ $scope.webhook_url = `${baseURL}${jobTemplateData.url}${newValue.value}`;
+ }
+ } else {
+ $scope.webhook_url = '';
+ }
+ sync_webhook_service_select2();
+ });
}
callback = function() {
@@ -759,6 +775,7 @@ export default
data.job_tags = (Array.isArray($scope.job_tags)) ? _.uniq($scope.job_tags).join() : "";
data.skip_tags = (Array.isArray($scope.skip_tags)) ? _.uniq($scope.skip_tags).join() : "";
+ delete data.webhook_url;
Rest.setUrl(defaultUrl + $state.params.job_template_id);
Rest.patch(data)
diff --git a/awx/ui/client/src/templates/job_templates/job-template.form.js b/awx/ui/client/src/templates/job_templates/job-template.form.js
index fb29d0b1fd..cb416484e2 100644
--- a/awx/ui/client/src/templates/job_templates/job-template.form.js
+++ b/awx/ui/client/src/templates/job_templates/job-template.form.js
@@ -406,6 +406,19 @@ function(NotificationsList, i18n) {
dataPlacement: 'right',
dataContainer: "body",
},
+ webhook_url: {
+ label: i18n._('Webhook URL'),
+ type: 'text',
+ readonly: true,
+ //ngShow: "allow_webhooks && allow_webhooks !== 'false'",
+ column: 2,
+ awPopOver: "webhook_url_help",
+ awPopOverWatch: "webhook_url_help",
+ dataPlacement: 'top',
+ dataTitle: i18n._('Webhook URL'),
+ dataContainer: "body",
+ ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAddJobTemplate)'
+ },
extra_vars: {
label: i18n._('Extra Variables'),
type: 'textarea',
From 151de89c26b6c41552cddfb5caf5d24d29995a9f Mon Sep 17 00:00:00 2001
From: Jake McDermott
Date: Thu, 5 Sep 2019 15:59:03 -0400
Subject: [PATCH 30/74] add webhook credential field
---
awx/ui/client/lib/theme/index.less | 1 +
.../job-template-add.controller.js | 126 ++++++++++++++-
.../job-template-edit.controller.js | 144 +++++++++++++++---
.../job_templates/job-template.form.js | 17 +++
.../src/templates/job_templates/main.js | 10 +-
.../job_templates/webhook-credential/index.js | 4 +
.../job_templates/webhook-credential/main.js | 9 ++
.../webhook-credential-input.component.js | 11 ++
.../webhook-credential-input.partial.html | 43 ++++++
.../webhook-credential.block.less | 113 ++++++++++++++
10 files changed, 449 insertions(+), 29 deletions(-)
create mode 100644 awx/ui/client/src/templates/job_templates/webhook-credential/index.js
create mode 100644 awx/ui/client/src/templates/job_templates/webhook-credential/main.js
create mode 100644 awx/ui/client/src/templates/job_templates/webhook-credential/webhook-credential-input.component.js
create mode 100644 awx/ui/client/src/templates/job_templates/webhook-credential/webhook-credential-input.partial.html
create mode 100644 awx/ui/client/src/templates/job_templates/webhook-credential/webhook-credential.block.less
diff --git a/awx/ui/client/lib/theme/index.less b/awx/ui/client/lib/theme/index.less
index a9f9d2bdde..c7baad45e4 100644
--- a/awx/ui/client/lib/theme/index.less
+++ b/awx/ui/client/lib/theme/index.less
@@ -112,6 +112,7 @@
@import '../../src/workflow-results/standard-out.block.less';
@import '../../src/templates/prompt/prompt.block.less';
@import '../../src/templates/job_templates/multi-credential/multi-credential.block.less';
+@import '../../src/templates/job_templates/webhook-credential/webhook-credential.block.less';
@import '../../src/templates/labels/labelsList.block.less';
@import '../../src/templates/survey-maker/survey-maker.block.less';
@import '../../src/templates/survey-maker/survey-maker.block.less';
diff --git a/awx/ui/client/src/templates/job_templates/add-job-template/job-template-add.controller.js b/awx/ui/client/src/templates/job_templates/add-job-template/job-template-add.controller.js
index 218f3353f7..3e8dc913b7 100644
--- a/awx/ui/client/src/templates/job_templates/add-job-template/job-template-add.controller.js
+++ b/awx/ui/client/src/templates/job_templates/add-job-template/job-template-add.controller.js
@@ -10,14 +10,14 @@
'ProcessErrors', 'GetBasePath', 'hashSetup', 'ParseTypeChange', 'Wait',
'Empty', 'ToJSON', 'CallbackHelpInit', 'GetChoices', '$state', 'availableLabels',
'CreateSelect2', '$q', 'i18n', 'Inventory', 'Project', 'InstanceGroupsService',
- 'MultiCredentialService', 'ConfigData', 'resolvedModels',
+ 'MultiCredentialService', 'ConfigData', 'resolvedModels', '$compile',
function(
$filter, $scope,
$stateParams, JobTemplateForm, GenerateForm, Rest, Alert,
ProcessErrors, GetBasePath, hashSetup, ParseTypeChange, Wait,
Empty, ToJSON, CallbackHelpInit, GetChoices,
$state, availableLabels, CreateSelect2, $q, i18n, Inventory, Project, InstanceGroupsService,
- MultiCredentialService, ConfigData, resolvedModels
+ MultiCredentialService, ConfigData, resolvedModels, $compile
) {
// Inject dynamic view
@@ -45,6 +45,113 @@
$scope.credentialNotPresent = false;
$scope.canGetAllRelatedResources = true;
+ //
+ // webhook credential - all handlers, dynamic state, etc. live here
+ //
+
+ $scope.webhookCredential = {
+ id: null,
+ name: null,
+ isModalOpen: false,
+ isModalReady: false,
+ modalTitle: i18n._('Select Webhook Credential'),
+ modalBaseParams: {
+ order_by: 'name',
+ page_size: 5,
+ credential_type__namespace: null,
+ },
+ modalSelectedId: null,
+ modalSelectedName: null,
+ };
+
+ $scope.handleWebhookCredentialLookupClick = () => {
+ $scope.webhookCredential.modalSelectedId = $scope.webhookCredential.id;
+ $scope.webhookCredential.isModalOpen = true;
+ };
+
+ $scope.handleWebhookCredentialTagDelete = () => {
+ $scope.webhookCredential.id = null;
+ $scope.webhookCredential.name = null;
+ };
+
+ $scope.handleWebhookCredentialModalClose = () => {
+ $scope.webhookCredential.isModalOpen = false;
+ $scope.webhookCredential.isModalReady = false;
+ };
+
+ $scope.handleWebhookCredentialModalReady = () => {
+ $scope.webhookCredential.isModalReady = true;
+ };
+
+ $scope.handleWebhookCredentialModalItemSelect = (item) => {
+ $scope.webhookCredential.modalSelectedId = item.id;
+ $scope.webhookCredential.modalSelectedName = item.name;
+ };
+
+ $scope.handleWebhookCredentialModalCancel = () => {
+ $scope.webhookCredential.isModalOpen = false;
+ $scope.webhookCredential.isModalReady = false;
+ $scope.webhookCredential.modalSelectedId = null;
+ $scope.webhookCredential.modalSelectedName = null;
+
+ };
+
+ $scope.handleWebhookCredentialSelect = () => {
+ $scope.webhookCredential.isModalOpen = false;
+ $scope.webhookCredential.isModalReady = false;
+ $scope.webhookCredential.id = $scope.webhookCredential.modalSelectedId;
+ $scope.webhookCredential.name = $scope.webhookCredential.modalSelectedName;
+ $scope.webhookCredential.modalSelectedId = null;
+ $scope.webhookCredential.modalSelectedName = null;
+ };
+
+ $('#content-container').append($compile(`
+
+
+
+
+ ${i18n._('CANCEL')}
+
+
+ ${i18n._('SELECT')}
+
+
+ `)($scope));
+
+ $scope.$watch('webhook_service', (newValue, oldValue) => {
+ const newServiceValue = newValue && typeof newValue === 'object' ? newValue.value : newValue;
+ const oldServiceValue = oldValue && typeof oldValue === 'object' ? oldValue.value : oldValue;
+ if (newServiceValue !== oldServiceValue || newServiceValue === newValue) {
+ $scope.webhook_service = { value: newServiceValue };
+ sync_webhook_service_select2();
+ $scope.webhookCredential.modalBaseParams.credential_type__namespace = newServiceValue ?
+ `${newServiceValue}_token`
+ : null;
+ if (newServiceValue !== newValue || newValue === null) {
+ $scope.webhookCredential.id = null;
+ $scope.webhookCredential.name = null;
+ }
+ }
+ });
+
hashSetup({
scope: $scope,
master: master,
@@ -182,6 +289,17 @@
});
}
+ function sync_webhook_service_select2() {
+ CreateSelect2({
+ element:'#webhook-service-select',
+ addNew: false,
+ multiple: false,
+ scope: $scope,
+ options: 'webhook_service_options',
+ model: 'webhook_service'
+ });
+ }
+
$scope.toggleForm = function(key) {
$scope[key] = !$scope[key];
};
@@ -344,6 +462,10 @@
delete data.credential;
delete data.vault_credential;
delete data.webhook_url;
+ data.webhook_credential = $scope.webhookCredential.id;
+ if (!data.webhook_credential) {
+ data.webhook_service = null;
+ }
data.extra_vars = ToJSON($scope.parseType, $scope.extra_vars, true);
diff --git a/awx/ui/client/src/templates/job_templates/edit-job-template/job-template-edit.controller.js b/awx/ui/client/src/templates/job_templates/edit-job-template/job-template-edit.controller.js
index 2c2a20ecb3..456ec4855f 100644
--- a/awx/ui/client/src/templates/job_templates/edit-job-template/job-template-edit.controller.js
+++ b/awx/ui/client/src/templates/job_templates/edit-job-template/job-template-edit.controller.js
@@ -19,7 +19,7 @@ export default
'initSurvey', '$state', 'CreateSelect2', 'isNotificationAdmin',
'ToggleNotification','$q', 'InstanceGroupsService', 'InstanceGroupsData',
'MultiCredentialService', 'availableLabels', 'projectGetPermissionDenied',
- 'inventoryGetPermissionDenied', 'jobTemplateData', 'ParseVariableString', 'ConfigData',
+ 'inventoryGetPermissionDenied', 'jobTemplateData', 'ParseVariableString', 'ConfigData', '$compile',
function(
$filter, $scope,
$stateParams, JobTemplateForm, GenerateForm, Rest, Alert,
@@ -29,7 +29,7 @@ export default
SurveyControllerInit, $state, CreateSelect2, isNotificationAdmin,
ToggleNotification, $q, InstanceGroupsService, InstanceGroupsData,
MultiCredentialService, availableLabels, projectGetPermissionDenied,
- inventoryGetPermissionDenied, jobTemplateData, ParseVariableString, ConfigData
+ inventoryGetPermissionDenied, jobTemplateData, ParseVariableString, ConfigData, $compile
) {
$scope.$watch('job_template_obj.summary_fields.user_capabilities.edit', function(val) {
@@ -63,7 +63,7 @@ export default
$scope.playbook_options = null;
$scope.webhook_service_options = null;
$scope.playbook = null;
- $scope.webhook_service = null;
+ $scope.webhook_service = jobTemplateData.webhook_service;
$scope.webhook_url = '';
$scope.mode = 'edit';
$scope.parseType = 'yaml';
@@ -77,6 +77,119 @@ export default
$scope.custom_virtualenvs_options = virtualEnvs;
$scope.webhook_url_help = i18n._('Webhook services can launch jobs with this job template by making a POST request to this URL.');
+ //
+ // webhook credential - all handlers, dynamic state, etc. live here
+ //
+
+ $scope.webhookCredential = {
+ id: _.get(jobTemplateData, ['summary_fields', 'webhook_credential', 'id']),
+ name: _.get(jobTemplateData, ['summary_fields', 'webhook_credential', 'name']),
+ isModalOpen: false,
+ isModalReady: false,
+ modalTitle: i18n._('Select Webhook Credential'),
+ modalBaseParams: {
+ order_by: 'name',
+ page_size: 5,
+ credential_type__namespace: `${jobTemplateData.webhook_service}_token`,
+ },
+ modalSelectedId: null,
+ modalSelectedName: null,
+ };
+
+ $scope.handleWebhookCredentialLookupClick = () => {
+ $scope.webhookCredential.modalSelectedId = $scope.webhookCredential.id;
+ $scope.webhookCredential.isModalOpen = true;
+ };
+
+ $scope.handleWebhookCredentialTagDelete = () => {
+ $scope.webhookCredential.id = null;
+ $scope.webhookCredential.name = null;
+ };
+
+ $scope.handleWebhookCredentialModalClose = () => {
+ $scope.webhookCredential.isModalOpen = false;
+ $scope.webhookCredential.isModalReady = false;
+ };
+
+ $scope.handleWebhookCredentialModalReady = () => {
+ $scope.webhookCredential.isModalReady = true;
+ };
+
+ $scope.handleWebhookCredentialModalItemSelect = (item) => {
+ $scope.webhookCredential.modalSelectedId = item.id;
+ $scope.webhookCredential.modalSelectedName = item.name;
+ };
+
+ $scope.handleWebhookCredentialModalCancel = () => {
+ $scope.webhookCredential.isModalOpen = false;
+ $scope.webhookCredential.isModalReady = false;
+ $scope.webhookCredential.modalSelectedId = null;
+ $scope.webhookCredential.modalSelectedName = null;
+
+ };
+
+ $scope.handleWebhookCredentialSelect = () => {
+ $scope.webhookCredential.isModalOpen = false;
+ $scope.webhookCredential.isModalReady = false;
+ $scope.webhookCredential.id = $scope.webhookCredential.modalSelectedId;
+ $scope.webhookCredential.name = $scope.webhookCredential.modalSelectedName;
+ $scope.webhookCredential.modalSelectedId = null;
+ $scope.webhookCredential.modalSelectedName = null;
+ };
+
+ $('#content-container').append($compile(`
+
+
+
+
+ ${i18n._('CANCEL')}
+
+
+ ${i18n._('SELECT')}
+
+
+ `)($scope));
+
+ $scope.$watch('webhook_service', (newValue, oldValue) => {
+ const newServiceValue = newValue && typeof newValue === 'object' ? newValue.value : newValue;
+ const oldServiceValue = oldValue && typeof oldValue === 'object' ? oldValue.value : oldValue;
+ if (newServiceValue) {
+ $scope.webhook_url = `${$scope.callback_server_path}${jobTemplateData.url}${newServiceValue}`;
+ } else {
+ $scope.webhook_url = '';
+ }
+ if (newServiceValue !== oldServiceValue || newServiceValue === newValue) {
+ $scope.webhook_service = { value: newServiceValue };
+ sync_webhook_service_select2();
+ $scope.webhookCredential.modalBaseParams.credential_type__namespace = newServiceValue ?
+ `${newServiceValue}_token` : null;
+ if (newServiceValue !== newValue || newValue === null) {
+ $scope.webhookCredential.id = null;
+ $scope.webhookCredential.name = null;
+ }
+ }
+ });
+
+ $scope.$watch('verbosity', sync_verbosity_select2);
+
SurveyControllerInit({
scope: $scope,
parent_scope: $scope,
@@ -178,24 +291,6 @@ export default
}
}
});
-
- // watch for changes to 'verbosity', ensure we keep our select2 in sync when it changes.
- $scope.$watch('verbosity', sync_verbosity_select2);
- $scope.$watch('webhook_service', (newValue) => {
- if (newValue) {
- // TODO: We'll need the host from the server.
- const baseURL = window.location.origin;
- if (typeof newValue === 'string') {
- $scope.webhook_url = `${baseURL}${jobTemplateData.url}${newValue}`;
- $scope.webhook_service = { value: newValue };
- } else {
- $scope.webhook_url = `${baseURL}${jobTemplateData.url}${newValue.value}`;
- }
- } else {
- $scope.webhook_url = '';
- }
- sync_webhook_service_select2();
- });
}
callback = function() {
@@ -229,7 +324,7 @@ export default
scope: $scope,
options: 'webhook_service_options',
model: 'webhook_service'
- }));
+ }));
}
function jobTemplateLoadFinished(){
@@ -775,7 +870,12 @@ export default
data.job_tags = (Array.isArray($scope.job_tags)) ? _.uniq($scope.job_tags).join() : "";
data.skip_tags = (Array.isArray($scope.skip_tags)) ? _.uniq($scope.skip_tags).join() : "";
+
delete data.webhook_url;
+ data.webhook_credential = $scope.webhookCredential.id;
+ if (!data.webhook_credential) {
+ data.webhook_service = null;
+ }
Rest.setUrl(defaultUrl + $state.params.job_template_id);
Rest.patch(data)
diff --git a/awx/ui/client/src/templates/job_templates/job-template.form.js b/awx/ui/client/src/templates/job_templates/job-template.form.js
index cb416484e2..f6c4c4d5b9 100644
--- a/awx/ui/client/src/templates/job_templates/job-template.form.js
+++ b/awx/ui/client/src/templates/job_templates/job-template.form.js
@@ -419,6 +419,23 @@ function(NotificationsList, i18n) {
dataContainer: "body",
ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAddJobTemplate)'
},
+ webhook_credential: {
+ label: i18n._('Webhook Credential'),
+ type: 'custom',
+ control: `
+ `,
+ awPopOver: "" + i18n._("Select the credential to use with the webhook service.") + "
",
+ dataTitle: i18n._('Webhook Credential'),
+ dataPlacement: 'right',
+ dataContainer: "body",
+ ngDisabled: 'canAddJobTemplate',
+ required: false,
+ },
extra_vars: {
label: i18n._('Extra Variables'),
type: 'textarea',
diff --git a/awx/ui/client/src/templates/job_templates/main.js b/awx/ui/client/src/templates/job_templates/main.js
index 0c096c0134..ee9538a3dd 100644
--- a/awx/ui/client/src/templates/job_templates/main.js
+++ b/awx/ui/client/src/templates/job_templates/main.js
@@ -1,13 +1,13 @@
import jobTemplateAdd from './add-job-template/main';
import jobTemplateEdit from './edit-job-template/main';
import multiCredential from './multi-credential/main';
+import webhookCredential from './webhook-credential';
import hashSetup from './factories/hash-setup.factory';
import CallbackHelpInit from './factories/callback-help-init.factory';
import JobTemplateForm from './job-template.form';
export default
- angular.module('jobTemplates', [jobTemplateAdd.name, jobTemplateEdit.name,
- multiCredential.name])
- .factory('hashSetup', hashSetup)
- .factory('CallbackHelpInit', CallbackHelpInit)
- .factory('JobTemplateForm', JobTemplateForm);
+ angular.module('jobTemplates', [jobTemplateAdd.name, jobTemplateEdit.name, multiCredential.name, webhookCredential.name])
+ .factory('hashSetup', hashSetup)
+ .factory('CallbackHelpInit', CallbackHelpInit)
+ .factory('JobTemplateForm', JobTemplateForm);
diff --git a/awx/ui/client/src/templates/job_templates/webhook-credential/index.js b/awx/ui/client/src/templates/job_templates/webhook-credential/index.js
new file mode 100644
index 0000000000..97b634463a
--- /dev/null
+++ b/awx/ui/client/src/templates/job_templates/webhook-credential/index.js
@@ -0,0 +1,4 @@
+import webhookCredentialInput from './webhook-credential-input.component';
+
+export default angular.module('webhookCredential', [])
+ .component('webhookCredentialInput', webhookCredentialInput);
diff --git a/awx/ui/client/src/templates/job_templates/webhook-credential/main.js b/awx/ui/client/src/templates/job_templates/webhook-credential/main.js
new file mode 100644
index 0000000000..a91d79dbc6
--- /dev/null
+++ b/awx/ui/client/src/templates/job_templates/webhook-credential/main.js
@@ -0,0 +1,9 @@
+import webhookCredential from './webhook-credential.directive';
+import webhookCredentialModal from './webhook-credential-modal.directive';
+import webhookCredentialService from './webhook-credential.service';
+
+export default
+ angular.module('webhookCredential', [])
+ .directive('webhookCredential', webhookCredential)
+ .directive('webhookCredentialModal', webhookCredentialModal)
+ .service('WebhookCredentialService', webhookCredentialService);
diff --git a/awx/ui/client/src/templates/job_templates/webhook-credential/webhook-credential-input.component.js b/awx/ui/client/src/templates/job_templates/webhook-credential/webhook-credential-input.component.js
new file mode 100644
index 0000000000..8f5d752948
--- /dev/null
+++ b/awx/ui/client/src/templates/job_templates/webhook-credential/webhook-credential-input.component.js
@@ -0,0 +1,11 @@
+const templateUrl = require('~src/templates/job_templates/webhook-credential/webhook-credential-input.partial.html');
+export default {
+ templateUrl,
+ controllerAs: 'vm',
+ bindings: {
+ isFieldDisabled: '<',
+ tagName: '<',
+ onLookupClick: '<',
+ onTagDelete: '<',
+ },
+};
diff --git a/awx/ui/client/src/templates/job_templates/webhook-credential/webhook-credential-input.partial.html b/awx/ui/client/src/templates/job_templates/webhook-credential/webhook-credential-input.partial.html
new file mode 100644
index 0000000000..268fb05f6e
--- /dev/null
+++ b/awx/ui/client/src/templates/job_templates/webhook-credential/webhook-credential-input.partial.html
@@ -0,0 +1,43 @@
+
diff --git a/awx/ui/client/src/templates/job_templates/webhook-credential/webhook-credential.block.less b/awx/ui/client/src/templates/job_templates/webhook-credential/webhook-credential.block.less
new file mode 100644
index 0000000000..24467c7d0f
--- /dev/null
+++ b/awx/ui/client/src/templates/job_templates/webhook-credential/webhook-credential.block.less
@@ -0,0 +1,113 @@
+.WebhookCredential-tags {
+ padding-left: 0px;
+}
+
+.WebhookCredential-flexContainer {
+ display: flex;
+ width: 100%;
+ flex-wrap: wrap;
+}
+
+.WebhookCredential-tagContainer {
+ display: flex;
+ max-width: 100%;
+ background-color: @default-link;
+ color: @default-bg;
+ border-radius: 5px;
+ padding: 0px 0px 0px 10px;
+ margin: 3px 10px 3px 0px;
+}
+
+.WebhookCredential-tagContainer--disabled {
+ background-color: @default-icon;
+}
+
+.WebhookCredential-tag {
+ font-size: 12px;
+ margin-right: 10px;
+ max-width: 100%;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ overflow: hidden;
+ padding: 2px 0px 2px 15px;
+}
+
+.WebhookCredential-tag--disabled {
+ border-top-left-radius: 0px;
+ border-bottom-left-radius: 0px;
+ padding-left: 10px;
+}
+
+.WebhookCredential-tag--deletable {
+ margin-right: 0px;
+ border-top-right-radius: 0px;
+ border-bottom-right-radius: 0px;
+ border-right: 0;
+ max-width: ~"calc(100% - 23px)";
+ padding-left: 10px;
+}
+
+.WebhookCredential-deleteContainer {
+ border-top-right-radius: 5px;
+ border-bottom-right-radius: 5px;
+ padding: 2px 5px;
+ align-items: center;
+ display: flex;
+ cursor: pointer;
+}
+
+.WebhookCredential-tagDelete {
+ font-size: 11px;
+}
+
+.WebhookCredential-iconContainer {
+ border-top-left-radius: 0px;
+ border-bottom-left-radius: 0px;
+ padding: 0px 5px;
+ margin: 3px 0px;
+ margin-left: -3px;
+ align-items: center;
+ display: flex;
+}
+
+.WebhookCredential-iconContainer--disabled {
+ border-top-left-radius: 5px;
+ border-bottom-left-radius: 5px;
+ padding: 0 5px;
+ padding-left: 10px;
+ margin: 3px 0px;
+ align-items: center;
+ display: flex;
+}
+
+
+.WebhookCredential-tagIcon {
+ margin: 0px 0px;
+ font-size: 12px;
+}
+
+.WebhookCredential-name {
+ flex: initial;
+ font-size: 12px;
+ max-width: 100%;
+}
+
+.WebhookCredential-name--label {
+ color: @default-list-header-bg;
+ font-size: 12px;
+ margin-left: -8px;
+ margin-right: 5px;
+}
+
+.WebhookCredential-tag--deletable > .WebhookCredential-name {
+ max-width: ~"calc(100% - 23px)";
+}
+
+.WebhookCredential-deleteContainer:hover {
+ border-color: @default-err;
+ background-color: @default-err!important;
+}
+
+.WebhookCredential-deleteContainer:hover > .WebhookCredential-tagDelete {
+ color: @default-bg;
+}
From 48eb50216180a00bd39900c009ed8164a92c9a6b Mon Sep 17 00:00:00 2001
From: Jake McDermott
Date: Thu, 5 Sep 2019 16:50:36 -0400
Subject: [PATCH 31/74] wip
---
.../job-template-edit.controller.js | 8 +++++
.../job_templates/job-template.form.js | 34 +++++++++++++++++--
2 files changed, 39 insertions(+), 3 deletions(-)
diff --git a/awx/ui/client/src/templates/job_templates/edit-job-template/job-template-edit.controller.js b/awx/ui/client/src/templates/job_templates/edit-job-template/job-template-edit.controller.js
index 456ec4855f..a534003acd 100644
--- a/awx/ui/client/src/templates/job_templates/edit-job-template/job-template-edit.controller.js
+++ b/awx/ui/client/src/templates/job_templates/edit-job-template/job-template-edit.controller.js
@@ -430,6 +430,14 @@ export default
default_val: dft
});
+ const defaultWebhookKey = ($scope.webhook_key === "" || $scope.webhook_key === null) ? false : true;
+ hashSetup({
+ scope: $scope,
+ master: master,
+ check_field: 'enable_webhooks',
+ default_val: defaultWebhookKey
+ });
+
ParseTypeChange({
scope: $scope,
field_id: 'extra_vars',
diff --git a/awx/ui/client/src/templates/job_templates/job-template.form.js b/awx/ui/client/src/templates/job_templates/job-template.form.js
index f6c4c4d5b9..c0e134119b 100644
--- a/awx/ui/client/src/templates/job_templates/job-template.form.js
+++ b/awx/ui/client/src/templates/job_templates/job-template.form.js
@@ -338,6 +338,17 @@ function(NotificationsList, i18n) {
dataTitle: i18n._('Allow Provisioning Callbacks'),
dataContainer: "body",
ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAddJobTemplate)'
+ }, {
+ name: 'enable_webhooks',
+ label: i18n._('Enable Webhook'),
+ type: 'checkbox',
+ ngChange: "toggleCallback('webhook_key')",
+ column: 2,
+ awPopOver: "" + i18n._("Enabled webhook for this job template.") + "
",
+ dataPlacement: 'right',
+ dataTitle: i18n._('Enable Webhook'),
+ dataContainer: "body",
+ ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAddJobTemplate)'
}, {
name: 'allow_simultaneous',
label: i18n._('Enable Concurrent Jobs'),
@@ -396,7 +407,7 @@ function(NotificationsList, i18n) {
type:'select',
defaultText: i18n._('Choose a Webhook Service'),
ngOptions: 'svc.label for svc in webhook_service_options track by svc.value',
- ngShow: true,
+ ngShow: "enable_webhooks && enable_webhooks !== 'false'",
ngDisabled: "!(job_template_obj.summary_fields.user_capabilities.edit || canAddJobTemplate) || !canGetAllRelatedResources",
id: 'webhook-service-select',
required: false,
@@ -409,8 +420,7 @@ function(NotificationsList, i18n) {
webhook_url: {
label: i18n._('Webhook URL'),
type: 'text',
- readonly: true,
- //ngShow: "allow_webhooks && allow_webhooks !== 'false'",
+ ngShow: "enable_webhooks && enable_webhooks !== 'false'",
column: 2,
awPopOver: "webhook_url_help",
awPopOverWatch: "webhook_url_help",
@@ -419,9 +429,27 @@ function(NotificationsList, i18n) {
dataContainer: "body",
ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAddJobTemplate)'
},
+ webhook_key: {
+ label: i18n._('Webhook Key'),
+ type: 'text',
+ ngShow: "enable_webhooks && enable_webhooks !== 'false'",
+ genHash: true,
+ column: 2,
+ awPopOver: "webhook_key_help",
+ awPopOverWatch: "webhook_key_help",
+ dataPlacement: 'right',
+ dataTitle: i18n._("Webhook Config Key"),
+ dataContainer: "body",
+ ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAddJobTemplate)',
+ awRequiredWhen: {
+ reqExpression: 'enable_webhooks',
+ alwaysShowAsterisk: true
+ }
+ },
webhook_credential: {
label: i18n._('Webhook Credential'),
type: 'custom',
+ ngShow: "enable_webhooks && enable_webhooks !== 'false'",
control: `
Date: Thu, 5 Sep 2019 19:32:42 -0400
Subject: [PATCH 32/74] issue network calls for setting and getting webhook key
---
.../job-template-edit.controller.js | 15 ++++++++++++---
awx/ui/client/src/templates/main.js | 16 +++++++++++++++-
2 files changed, 27 insertions(+), 4 deletions(-)
diff --git a/awx/ui/client/src/templates/job_templates/edit-job-template/job-template-edit.controller.js b/awx/ui/client/src/templates/job_templates/edit-job-template/job-template-edit.controller.js
index a534003acd..a86456190a 100644
--- a/awx/ui/client/src/templates/job_templates/edit-job-template/job-template-edit.controller.js
+++ b/awx/ui/client/src/templates/job_templates/edit-job-template/job-template-edit.controller.js
@@ -19,7 +19,7 @@ export default
'initSurvey', '$state', 'CreateSelect2', 'isNotificationAdmin',
'ToggleNotification','$q', 'InstanceGroupsService', 'InstanceGroupsData',
'MultiCredentialService', 'availableLabels', 'projectGetPermissionDenied',
- 'inventoryGetPermissionDenied', 'jobTemplateData', 'ParseVariableString', 'ConfigData', '$compile',
+ 'inventoryGetPermissionDenied', 'jobTemplateData', 'ParseVariableString', 'ConfigData', '$compile', 'webhookKey',
function(
$filter, $scope,
$stateParams, JobTemplateForm, GenerateForm, Rest, Alert,
@@ -29,7 +29,7 @@ export default
SurveyControllerInit, $state, CreateSelect2, isNotificationAdmin,
ToggleNotification, $q, InstanceGroupsService, InstanceGroupsData,
MultiCredentialService, availableLabels, projectGetPermissionDenied,
- inventoryGetPermissionDenied, jobTemplateData, ParseVariableString, ConfigData, $compile
+ inventoryGetPermissionDenied, jobTemplateData, ParseVariableString, ConfigData, $compile, webhookKey
) {
$scope.$watch('job_template_obj.summary_fields.user_capabilities.edit', function(val) {
@@ -80,7 +80,7 @@ export default
//
// webhook credential - all handlers, dynamic state, etc. live here
//
-
+ $scope.webhook_key = webhookKey;
$scope.webhookCredential = {
id: _.get(jobTemplateData, ['summary_fields', 'webhook_credential', 'id']),
name: _.get(jobTemplateData, ['summary_fields', 'webhook_credential', 'name']),
@@ -704,6 +704,13 @@ export default
});
+ let webhookKeyPromise = Promise.resolve();
+ if ($scope.webhook_key !== webhookKey) {
+ Rest.setUrl(jobTemplateData.related.webhook_key);
+ webhookKeyPromise = Rest.post({ webhook_key: $scope.webhook_key });
+ }
+
+
var orgDefer = $q.defer();
var associationDefer = $q.defer();
var associatedLabelsDefer = $q.defer();
@@ -776,6 +783,7 @@ export default
for (var i = 0; i < toPost.length; i++) {
defers.push(Rest.post(toPost[i]));
}
+ defers.push(webhookKeyPromise);
$q.all(defers)
.then(function() {
Wait('stop');
@@ -884,6 +892,7 @@ export default
if (!data.webhook_credential) {
data.webhook_service = null;
}
+ delete data.webhook_key;
Rest.setUrl(defaultUrl + $state.params.job_template_id);
Rest.patch(data)
diff --git a/awx/ui/client/src/templates/main.js b/awx/ui/client/src/templates/main.js
index 4f301cdb91..08b6ee30f4 100644
--- a/awx/ui/client/src/templates/main.js
+++ b/awx/ui/client/src/templates/main.js
@@ -296,7 +296,21 @@ angular.module('templates', [surveyMaker.name, jobTemplates.name, labels.name, p
msg: i18n._('Failed to get organizations for which this user is a notification administrator. GET returned ') + status
});
});
- }]
+ }],
+ webhookKey: ['Rest', 'ProcessErrors', 'jobTemplateData', 'i18n',
+ function(Rest, ProcessErrors, jobTemplateData, i18n) {
+ Rest.setUrl(jobTemplateData.related.webhook_key);
+ return Rest.get()
+ .then(({ data = {} }) => {
+ return data.webhook_key || '';
+ })
+ .catch(({data, status}) => {
+ ProcessErrors(null, data, status, null, {
+ hdr: i18n._('Error!'),
+ msg: i18n._('Failed to get webhook key GET returned ') + status
+ });
+ });
+ }],
}
}
});
From 7aa424b2106cc24dfbcd68d6008bee7db2b99ad9 Mon Sep 17 00:00:00 2001
From: Jeff Bradberry
Date: Fri, 6 Sep 2019 11:36:18 -0400
Subject: [PATCH 33/74] Make sure that the new webhook fields are populated
when firing off a job
Also, added a temporary hacky workaround for the fact that something
in our request/response stack for APIView is consuming the request
contents in an unfriendly way, preventing the `.body` @property from
working.
---
awx/api/generics.py | 3 +++
awx/api/views/webhooks.py | 25 ++++++++++++++-----------
2 files changed, 17 insertions(+), 11 deletions(-)
diff --git a/awx/api/generics.py b/awx/api/generics.py
index be58b057d8..952fadf450 100644
--- a/awx/api/generics.py
+++ b/awx/api/generics.py
@@ -164,6 +164,9 @@ class APIView(views.APIView):
if custom_header.startswith('HTTP_'):
request.environ.pop(custom_header, None)
+ # WTF, FIXME
+ request.body
+
drf_request = super(APIView, self).initialize_request(request, *args, **kwargs)
request.drf_request = drf_request
try:
diff --git a/awx/api/views/webhooks.py b/awx/api/views/webhooks.py
index 31b9755575..6e2e2cc5bd 100644
--- a/awx/api/views/webhooks.py
+++ b/awx/api/views/webhooks.py
@@ -87,7 +87,9 @@ class WebhookReceiverBase(APIView):
if not obj.webhook_key:
raise PermissionDenied
- mac = hmac.new(force_bytes(obj.webhook_key), msg=force_bytes(self.request.read()), digestmod=sha1)
+ 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("calculated signature: %s", force_bytes(mac.hexdigest()))
if not hmac.compare_digest(force_bytes(mac.hexdigest()), self.get_signature()):
raise PermissionDenied
@@ -112,16 +114,17 @@ class WebhookReceiverBase(APIView):
logger.debug("Webhook previously received, returning without action.")
return Response(status=status.HTTP_202_ACCEPTED)
- data = {
- 'tower_webhook_event_type': event_type,
- 'tower_webhook_event_guid': event_guid,
- 'tower_webhook_payload': request.data,
- }
new_job = obj.create_unified_job(
- webhook_service=obj.webhook_service,
- webhook_credential=obj.webhook_credential,
- webhook_guid=event_guid,
- extra_vars=json.dumps(data)
+ _eager_fields={
+ 'webhook_service': obj.webhook_service,
+ 'webhook_credential': obj.webhook_credential,
+ 'webhook_guid': event_guid,
+ },
+ extra_vars=json.dumps({
+ 'tower_webhook_event_type': event_type,
+ 'tower_webhook_event_guid': event_guid,
+ 'tower_webhook_payload': request.data,
+ })
)
new_job.signal_start()
@@ -156,7 +159,7 @@ class GitlabWebhookReceiver(WebhookReceiverBase):
def get_event_guid(self):
# Gitlab does not provide a unique identifier on events, so construct one.
h = sha1()
- h.update(force_bytes(self.request.read()))
+ h.update(force_bytes(self.request.body))
return h.hexdigest()
def get_signature(self):
From dd6c97ed87488d8fea51d7f70f8f123b524c693c Mon Sep 17 00:00:00 2001
From: Jeff Bradberry
Date: Fri, 6 Sep 2019 11:48:29 -0400
Subject: [PATCH 34/74] Include a message in the webhook response
---
awx/api/views/webhooks.py | 5 +++--
1 file changed, 3 insertions(+), 2 deletions(-)
diff --git a/awx/api/views/webhooks.py b/awx/api/views/webhooks.py
index 6e2e2cc5bd..0919414744 100644
--- a/awx/api/views/webhooks.py
+++ b/awx/api/views/webhooks.py
@@ -112,7 +112,8 @@ class WebhookReceiverBase(APIView):
if WorkflowJob.objects.filter(**kwargs).exists() or Job.objects.filter(**kwargs).exists():
# Short circuit if this webhook has already been received and acted upon.
logger.debug("Webhook previously received, returning without action.")
- return Response(status=status.HTTP_202_ACCEPTED)
+ return Response({'message': "Webhook previously received, aborting."},
+ status=status.HTTP_202_ACCEPTED)
new_job = obj.create_unified_job(
_eager_fields={
@@ -128,7 +129,7 @@ class WebhookReceiverBase(APIView):
)
new_job.signal_start()
- return Response(status=status.HTTP_202_ACCEPTED)
+ return Response({'message': "Job queued."}, status=status.HTTP_202_ACCEPTED)
class GithubWebhookReceiver(WebhookReceiverBase):
From 58e5f0212996d5d558fefc90a4e32fbffa5489a5 Mon Sep 17 00:00:00 2001
From: Jeff Bradberry
Date: Fri, 6 Sep 2019 13:33:53 -0400
Subject: [PATCH 35/74] Expose the new webhook fields in the job and workflow
serializers
---
awx/api/serializers.py | 17 ++++++++++-------
1 file changed, 10 insertions(+), 7 deletions(-)
diff --git a/awx/api/serializers.py b/awx/api/serializers.py
index cd81f7107f..1d824afd9f 100644
--- a/awx/api/serializers.py
+++ b/awx/api/serializers.py
@@ -2956,9 +2956,11 @@ class JobSerializer(UnifiedJobSerializer, JobOptionsSerializer):
class Meta:
model = Job
- fields = ('*', 'job_template', 'passwords_needed_to_start',
- 'allow_simultaneous', 'artifacts', 'scm_revision',
- 'instance_group', 'diff_mode', 'job_slice_number', 'job_slice_count')
+ fields = (
+ '*', 'job_template', 'passwords_needed_to_start', 'allow_simultaneous',
+ 'artifacts', 'scm_revision', 'instance_group', 'diff_mode', 'job_slice_number',
+ 'job_slice_count', 'webhook_service', 'webhook_credential', 'webhook_guid',
+ )
def get_related(self, obj):
res = super(JobSerializer, self).get_related(obj)
@@ -3417,10 +3419,11 @@ class WorkflowJobSerializer(LabelsListMixin, UnifiedJobSerializer):
class Meta:
model = WorkflowJob
- fields = ('*', 'workflow_job_template', 'extra_vars', 'allow_simultaneous',
- 'job_template', 'is_sliced_job',
- '-execution_node', '-event_processing_finished', '-controller_node',
- 'inventory', 'limit', 'scm_branch',)
+ fields = (
+ '*', 'workflow_job_template', 'extra_vars', 'allow_simultaneous', 'job_template',
+ 'is_sliced_job', '-execution_node', '-event_processing_finished', '-controller_node',
+ 'inventory', 'limit', 'scm_branch', 'webhook_service', 'webhook_credential', 'webhook_guid',
+ )
def get_related(self, obj):
res = super(WorkflowJobSerializer, self).get_related(obj)
From 178a2c7c495db5a8cc938e0bad803c7f40d56472 Mon Sep 17 00:00:00 2001
From: Jeff Bradberry
Date: Fri, 6 Sep 2019 14:59:26 -0400
Subject: [PATCH 36/74] Disable the authentication classes for the webhook
receivers
One of them was consuming the body of the posts. We do still need to
have an extraneous `request.body` expression, though now in
WebhookReceiverBase.post, since the `request.data` expression in the
logging also consumes the request body.
---
awx/api/generics.py | 3 ---
awx/api/views/webhooks.py | 4 ++++
2 files changed, 4 insertions(+), 3 deletions(-)
diff --git a/awx/api/generics.py b/awx/api/generics.py
index 952fadf450..be58b057d8 100644
--- a/awx/api/generics.py
+++ b/awx/api/generics.py
@@ -164,9 +164,6 @@ class APIView(views.APIView):
if custom_header.startswith('HTTP_'):
request.environ.pop(custom_header, None)
- # WTF, FIXME
- request.body
-
drf_request = super(APIView, self).initialize_request(request, *args, **kwargs)
request.drf_request = drf_request
try:
diff --git a/awx/api/views/webhooks.py b/awx/api/views/webhooks.py
index 0919414744..bb06361913 100644
--- a/awx/api/views/webhooks.py
+++ b/awx/api/views/webhooks.py
@@ -51,6 +51,7 @@ class WebhookReceiverBase(APIView):
lookup_field = 'pk'
permission_classes = (AllowAny,)
+ authentication_classes = ()
def get_queryset(self):
qs_models = {
@@ -95,6 +96,9 @@ class WebhookReceiverBase(APIView):
@csrf_exempt
def post(self, request, *args, **kwargs):
+ # Ensure that the full contents of the request are captured for multiple uses.
+ request.body
+
logger.debug(
"headers: {}\n"
"data: {}\n".format(request.headers, request.data)
From 5f7bfaa20a7e36082bd92c50c3971a9c24e889dd Mon Sep 17 00:00:00 2001
From: Jake McDermott
Date: Mon, 9 Sep 2019 13:04:00 -0400
Subject: [PATCH 37/74] support server-side webhook key generation
---
awx/ui/client/src/shared/form-generator.js | 18 ++++-
.../job-template-add.controller.js | 7 ++
.../job-template-edit.controller.js | 76 +++++++++++++------
.../job_templates/job-template.form.js | 47 +++++++-----
4 files changed, 102 insertions(+), 46 deletions(-)
diff --git a/awx/ui/client/src/shared/form-generator.js b/awx/ui/client/src/shared/form-generator.js
index 5f0330b4b2..18c5bf2eee 100644
--- a/awx/ui/client/src/shared/form-generator.js
+++ b/awx/ui/client/src/shared/form-generator.js
@@ -794,9 +794,21 @@ angular.module('FormGenerator', [GeneratorHelpers.name, 'Utilities', listGenerat
}
if (field.genHash) {
- html += "\n\n";
+ const defaultGenHashButtonTemplate = `
+
+
+ `;
+ const genHashButtonTemplate = _.get(field, 'genHashButtonTemplate', defaultGenHashButtonTemplate);
+ html += `${genHashButtonTemplate}\n\n`;
}
// Add error messages
diff --git a/awx/ui/client/src/templates/job_templates/add-job-template/job-template-add.controller.js b/awx/ui/client/src/templates/job_templates/add-job-template/job-template-add.controller.js
index 3e8dc913b7..7463b5f9a7 100644
--- a/awx/ui/client/src/templates/job_templates/add-job-template/job-template-add.controller.js
+++ b/awx/ui/client/src/templates/job_templates/add-job-template/job-template-add.controller.js
@@ -44,6 +44,7 @@
$scope.parseType = 'yaml';
$scope.credentialNotPresent = false;
$scope.canGetAllRelatedResources = true;
+ $scope.webhook_key_help = i18n._('Webhook services can use this as a shared secret.');
//
// webhook credential - all handlers, dynamic state, etc. live here
@@ -105,6 +106,8 @@
$scope.webhookCredential.modalSelectedName = null;
};
+ $scope.handleWebhookKeyButtonClick = () => {};
+
$('#content-container').append($compile(`
{
@@ -125,7 +129,6 @@ export default
$scope.webhookCredential.isModalReady = false;
$scope.webhookCredential.modalSelectedId = null;
$scope.webhookCredential.modalSelectedName = null;
-
};
$scope.handleWebhookCredentialSelect = () => {
@@ -137,6 +140,23 @@ export default
$scope.webhookCredential.modalSelectedName = null;
};
+ $scope.handleWebhookKeyButtonClick = () => {
+ Rest.setUrl(jobTemplateData.related.webhook_key);
+ Wait('start');
+ Rest.post({})
+ .then(({ data }) => {
+ $scope.currentlySavedWebhookKey = data.webhook_key;
+ $scope.webhook_key = data.webhook_key;
+ })
+ .catch(({ data }) => {
+ const errorMsg = `Failed to generate new webhook key. POST returned status: ${status}`;
+ ProcessErrors($scope, data, status, form, { hdr: 'Error!', msg: errorMsg });
+ })
+ .finally(() => {
+ Wait('stop');
+ });
+ };
+
$('#content-container').append($compile(`
" + i18n._("Enabled webhook for this job template.") + "
",
dataPlacement: 'right',
@@ -407,10 +406,9 @@ function(NotificationsList, i18n) {
type:'select',
defaultText: i18n._('Choose a Webhook Service'),
ngOptions: 'svc.label for svc in webhook_service_options track by svc.value',
- ngShow: "enable_webhooks && enable_webhooks !== 'false'",
+ ngShow: "enable_webhook && enable_webhook !== 'false'",
ngDisabled: "!(job_template_obj.summary_fields.user_capabilities.edit || canAddJobTemplate) || !canGetAllRelatedResources",
id: 'webhook-service-select',
- required: false,
column: 1,
awPopOver: "" + i18n._("Select a webhook service.") + "
",
dataTitle: i18n._('Webhook Service'),
@@ -420,36 +418,49 @@ function(NotificationsList, i18n) {
webhook_url: {
label: i18n._('Webhook URL'),
type: 'text',
- ngShow: "enable_webhooks && enable_webhooks !== 'false'",
- column: 2,
+ ngShow: "job_template_obj && enable_webhook && enable_webhook !== 'false'",
awPopOver: "webhook_url_help",
awPopOverWatch: "webhook_url_help",
dataPlacement: 'top',
dataTitle: i18n._('Webhook URL'),
dataContainer: "body",
- ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAddJobTemplate)'
+ readonly: true
},
webhook_key: {
label: i18n._('Webhook Key'),
type: 'text',
- ngShow: "enable_webhooks && enable_webhooks !== 'false'",
+ ngShow: "enable_webhook && enable_webhook !== 'false'",
genHash: true,
- column: 2,
+ genHashButtonTemplate: `
+
+
+
+ `,
+ genHashButtonClickHandlerName: "handleWebhookKeyButtonClick",
awPopOver: "webhook_key_help",
awPopOverWatch: "webhook_key_help",
dataPlacement: 'right',
- dataTitle: i18n._("Webhook Config Key"),
+ dataTitle: i18n._("Webhook Key"),
dataContainer: "body",
- ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAddJobTemplate)',
- awRequiredWhen: {
- reqExpression: 'enable_webhooks',
- alwaysShowAsterisk: true
- }
+ readonly: true,
+ required: false,
},
webhook_credential: {
label: i18n._('Webhook Credential'),
type: 'custom',
- ngShow: "enable_webhooks && enable_webhooks !== 'false'",
+ ngShow: "enable_webhook && enable_webhook !== 'false'",
control: `
Date: Mon, 9 Sep 2019 13:11:11 -0400
Subject: [PATCH 38/74] use key icon for webhook cred
---
.../webhook-credential/webhook-credential-input.partial.html | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/awx/ui/client/src/templates/job_templates/webhook-credential/webhook-credential-input.partial.html b/awx/ui/client/src/templates/job_templates/webhook-credential/webhook-credential-input.partial.html
index 268fb05f6e..f34c70b8e7 100644
--- a/awx/ui/client/src/templates/job_templates/webhook-credential/webhook-credential-input.partial.html
+++ b/awx/ui/client/src/templates/job_templates/webhook-credential/webhook-credential-input.partial.html
@@ -17,10 +17,10 @@
-
+
-
+
Date: Mon, 9 Sep 2019 19:26:14 -0400
Subject: [PATCH 39/74] add webhook fields to workflows
---
.../job-template-edit.controller.js | 2 +-
.../job_templates/job-template.form.js | 4 +-
awx/ui/client/src/templates/main.js | 16 +-
awx/ui/client/src/templates/workflows.form.js | 86 +++++++-
.../add-workflow/workflow-add.controller.js | 155 +++++++++++++-
.../edit-workflow/workflow-edit.controller.js | 196 +++++++++++++++++-
6 files changed, 450 insertions(+), 9 deletions(-)
diff --git a/awx/ui/client/src/templates/job_templates/edit-job-template/job-template-edit.controller.js b/awx/ui/client/src/templates/job_templates/edit-job-template/job-template-edit.controller.js
index f2bcad5fb8..435a938a51 100644
--- a/awx/ui/client/src/templates/job_templates/edit-job-template/job-template-edit.controller.js
+++ b/awx/ui/client/src/templates/job_templates/edit-job-template/job-template-edit.controller.js
@@ -195,6 +195,7 @@ export default
$scope.webhook_url = `${$scope.callback_server_path}${jobTemplateData.url}${newServiceValue}`;
} else {
$scope.webhook_url = '';
+ $scope.webhook_key = '';
}
if (newServiceValue !== oldServiceValue || newServiceValue === newValue) {
$scope.webhook_service = { value: newServiceValue };
@@ -355,7 +356,6 @@ export default
}
function jobTemplateLoadFinished(){
- //$scope.webhook_service = jobTemplateData.webhook_service;
select2LoadDefer.push(CreateSelect2({
element:'#job_template_job_type',
multiple: false
diff --git a/awx/ui/client/src/templates/job_templates/job-template.form.js b/awx/ui/client/src/templates/job_templates/job-template.form.js
index 2c1c11667e..7a87f99a56 100644
--- a/awx/ui/client/src/templates/job_templates/job-template.form.js
+++ b/awx/ui/client/src/templates/job_templates/job-template.form.js
@@ -433,7 +433,7 @@ function(NotificationsList, i18n) {
genHash: true,
genHashButtonTemplate: `
+
+
+
+
+
+
+
From 5e9448a85429aaaeddb595c8de60785cf4573a20 Mon Sep 17 00:00:00 2001
From: Jake McDermott
Date: Tue, 10 Sep 2019 10:39:01 -0400
Subject: [PATCH 41/74] always show launched by webhook details if there's a
webhook guid
---
.../features/output/details.component.js | 18 +++++++++---------
.../workflow-results.partial.html | 4 ++--
2 files changed, 11 insertions(+), 11 deletions(-)
diff --git a/awx/ui/client/features/output/details.component.js b/awx/ui/client/features/output/details.component.js
index 47cad34468..a20713bd73 100644
--- a/awx/ui/client/features/output/details.component.js
+++ b/awx/ui/client/features/output/details.component.js
@@ -266,15 +266,7 @@ function getLaunchedByDetails () {
let tooltip;
let value;
- if (createdBy) {
- tooltip = strings.get('tooltips.USER');
- link = `/#/users/${createdBy.id}`;
- value = $filter('sanitize')(createdBy.username);
- } else if (relatedSchedule && jobTemplate) {
- tooltip = strings.get('tooltips.SCHEDULE');
- link = `/#/templates/job_template/${jobTemplate.id}/schedules/${schedule.id}`;
- value = $filter('sanitize')(schedule.name);
- } else if (webhookGUID && jobTemplate) {
+ if (webhookGUID && jobTemplate) {
tooltip = strings.get('tooltips.WEBHOOK_JOB_TEMPLATE');
link = `/#/templates/job_template/${jobTemplate.id}`;
value = strings.get('details.WEBHOOK');
@@ -282,6 +274,14 @@ function getLaunchedByDetails () {
tooltip = strings.get('tooltips.WEBHOOK_WORKFLOW_JOB_TEMPLATE');
link = `/#/templates/workflow_job_template/${workflowJobTemplate.id}`;
value = strings.get('details.WEBHOOK');
+ } else if (createdBy) {
+ tooltip = strings.get('tooltips.USER');
+ link = `/#/users/${createdBy.id}`;
+ value = $filter('sanitize')(createdBy.username);
+ } else if (relatedSchedule && jobTemplate) {
+ tooltip = strings.get('tooltips.SCHEDULE');
+ link = `/#/templates/job_template/${jobTemplate.id}/schedules/${schedule.id}`;
+ value = $filter('sanitize')(schedule.name);
} else {
tooltip = null;
link = null;
diff --git a/awx/ui/client/src/workflow-results/workflow-results.partial.html b/awx/ui/client/src/workflow-results/workflow-results.partial.html
index ca735286ec..d2c87893c1 100644
--- a/awx/ui/client/src/workflow-results/workflow-results.partial.html
+++ b/awx/ui/client/src/workflow-results/workflow-results.partial.html
@@ -177,7 +177,7 @@
+ ng-show="workflow.summary_fields.created_by.username && !launched_by_webhook_link">
@@ -192,7 +192,7 @@
+ ng-show="workflow.summary_fields.schedule.name && !launched_by_webhook_link">