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 @@ +
+ + + + +
+
+
+
+
+ +
+
+ +
+
+ + {{ vm.tagName }} + +
+
+ +
+
+
+
+
+
+
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: ` + + `, + genHashButtonClickHandlerName: "handleWebhookKeyButtonClick", + awPopOver: "webhook_key_help", + awPopOverWatch: "webhook_key_help", + dataPlacement: 'right', + dataTitle: i18n._("Webhook Key"), + dataContainer: "body", + readonly: true, + required: false, + }, + webhook_credential: { + label: i18n._('Webhook Credential'), + type: 'custom', + ngShow: "enable_webhook && enable_webhook !== 'false'", + control: ` + `, + awPopOver: "

" + i18n._("Select the credential to use with the webhook service.") + "

", + dataTitle: i18n._('Webhook Credential'), + dataPlacement: 'right', + dataContainer: "body", + ngDisabled: '!webhook_service.value', + required: false, + }, }, buttons: { //for now always generates
+ + +
+ + +
+
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">