From 769fbadda6216dc54dcddc412505d0ef736c2323 Mon Sep 17 00:00:00 2001 From: fernandorocagonzalez Date: Sat, 4 Jul 2026 12:25:38 +0200 Subject: [PATCH 1/3] Add webhook support to Projects to sync on repository push A push to the configured GitHub/GitLab/Bitbucket DC repository now triggers the same update the Sync button does, so the local copy of the project follows the repo without polling schedules or paying the update-on-launch penalty on every job. Projects reuse the existing webhook receivers and signature checks. Only push and tag push events start a sync, everything else is acknowledged and ignored. An optional fnmatch ref filter (webhook_ref_filter) limits which refs cause a sync, and project updates record webhook_service and webhook_guid so duplicate deliveries are dropped, same as jobs. The webhook credential handling stays on the template mixin; projects only take the service/key part (WebhookKeyTemplateMixin) since a sync has no status to post back. --- awx/api/serializers.py | 12 +- awx/api/templates/api/webhook_key_view.md | 6 +- awx/api/urls/project.py | 3 +- awx/api/views/webhooks.py | 61 +++++- awx/main/migrations/0201_project_webhooks.py | 53 +++++ awx/main/models/mixins.py | 29 ++- awx/main/models/projects.py | 24 +- .../tests/functional/api/test_webhooks.py | 206 ++++++++++++++++++ awx/ui/src/api/models/Projects.js | 8 + awx/ui/src/screens/Project/Project.js | 12 + .../screens/Project/ProjectAdd/ProjectAdd.js | 7 +- .../Project/ProjectDetail/ProjectDetail.js | 29 +++ .../Project/ProjectEdit/ProjectEdit.js | 7 +- .../Project/shared/Project.helptext.js | 11 + .../src/screens/Project/shared/ProjectForm.js | 7 + .../Project/shared/ProjectForm.test.js | 46 ++++ .../shared/ProjectSubForms/SharedFields.js | 66 +++++- .../screens/Template/shared/WebhookSubForm.js | 38 +++- .../Template/shared/WebhookSubForm.test.js | 69 +++++- docs/docsite/rst/userguide/webhooks.rst | 15 ++ 20 files changed, 671 insertions(+), 38 deletions(-) create mode 100644 awx/main/migrations/0201_project_webhooks.py diff --git a/awx/api/serializers.py b/awx/api/serializers.py index aab4219de..281f6c0c0 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -1513,6 +1513,8 @@ class Meta: 'allow_override', 'default_environment', 'signature_validation_credential', + 'webhook_service', + 'webhook_ref_filter', ) + ( 'last_update_failed', 'last_updated', @@ -1537,6 +1539,12 @@ def get_related(self, obj): access_list=self.reverse('api:project_access_list', kwargs={'pk': obj.pk}), object_roles=self.reverse('api:project_object_roles_list', kwargs={'pk': obj.pk}), copy=self.reverse('api:project_copy', kwargs={'pk': obj.pk}), + webhook_key=self.reverse('api:webhook_key', kwargs={'model_kwarg': 'projects', 'pk': obj.pk}), + webhook_receiver=( + self.reverse('api:webhook_receiver_{}'.format(obj.webhook_service), kwargs={'model_kwarg': 'projects', 'pk': obj.pk}) + if obj.webhook_service + else '' + ), ) ) if obj.organization: @@ -1581,6 +1589,8 @@ def get_field_from_model_or_attrs(fd): for fd in ('scm_update_on_launch', 'scm_delete_on_update', 'scm_track_submodules', 'scm_clean'): if get_field_from_model_or_attrs(fd): raise serializers.ValidationError({fd: _('Update options must be set to false for manual projects.')}) + if get_field_from_model_or_attrs('webhook_service') and not get_field_from_model_or_attrs('scm_type'): + raise serializers.ValidationError({'webhook_service': _('Webhooks are not supported for manual projects.')}) return super(ProjectSerializer, self).validate(attrs) @@ -1625,7 +1635,7 @@ class Meta: class ProjectUpdateSerializer(UnifiedJobSerializer, ProjectOptionsSerializer): class Meta: model = ProjectUpdate - fields = ('*', 'project', 'job_type', 'job_tags', '-controller_node') + fields = ('*', 'project', 'job_type', 'job_tags', '-controller_node', 'webhook_service', 'webhook_guid') def get_related(self, obj): res = super(ProjectUpdateSerializer, self).get_related(obj) diff --git a/awx/api/templates/api/webhook_key_view.md b/awx/api/templates/api/webhook_key_view.md index ec83c4a04..d5644ec01 100644 --- a/awx/api/templates/api/webhook_key_view.md +++ b/awx/api/templates/api/webhook_key_view.md @@ -1,11 +1,11 @@ Webhook Secret Key: Make a GET request to this resource to obtain the secret key for a job -template or workflow job template configured to be triggered by -webhook events. The response will include the following fields: +template, workflow job template or project configured to be triggered +by webhook events. The response will include the following fields: * `webhook_key`: Secret key that needs to be copied and added to the - webhook configuration of the service this template will be receiving + webhook configuration of the service this resource will be receiving webhook events from (string, read-only) Make an empty POST request to this resource to generate a new diff --git a/awx/api/urls/project.py b/awx/api/urls/project.py index 56e82d49c..d9e55014a 100644 --- a/awx/api/urls/project.py +++ b/awx/api/urls/project.py @@ -1,7 +1,7 @@ # Copyright (c) 2017 Ansible, Inc. # All Rights Reserved. -from django.urls import path +from django.urls import include, path from awx.api.views import ( ProjectList, @@ -47,6 +47,7 @@ path('/object_roles/', ProjectObjectRolesList.as_view(), name='project_object_roles_list'), path('/access_list/', ProjectAccessList.as_view(), name='project_access_list'), path('/copy/', ProjectCopy.as_view(), name='project_copy'), + path('/', include('awx.api.urls.webhooks'), {'model_kwarg': 'projects'}), ] __all__ = ['urls'] diff --git a/awx/api/views/webhooks.py b/awx/api/views/webhooks.py index 82be5d341..8af2d1227 100644 --- a/awx/api/views/webhooks.py +++ b/awx/api/views/webhooks.py @@ -1,3 +1,4 @@ +from fnmatch import fnmatchcase from hashlib import sha1, sha256 import hmac import logging @@ -15,7 +16,7 @@ from awx.api import serializers from awx.api.generics import APIView, GenericAPIView from awx.api.permissions import WebhookKeyPermission -from awx.main.models import Job, JobTemplate, WorkflowJob, WorkflowJobTemplate +from awx.main.models import Job, JobTemplate, Project, ProjectUpdate, WorkflowJob, WorkflowJobTemplate from awx.main.constants import JOB_VARIABLE_PREFIXES logger = logging.getLogger('awx.api.views.webhooks') @@ -26,7 +27,7 @@ class WebhookKeyView(GenericAPIView): permission_classes = (WebhookKeyPermission,) def get_queryset(self): - qs_models = {'job_templates': JobTemplate, 'workflow_job_templates': WorkflowJobTemplate} + qs_models = {'job_templates': JobTemplate, 'workflow_job_templates': WorkflowJobTemplate, 'projects': Project} self.model = qs_models.get(self.kwargs['model_kwarg']) return super().get_queryset() @@ -52,9 +53,11 @@ class WebhookReceiverBase(APIView): authentication_classes = () ref_keys = {} + ref_name_keys = {} + project_sync_events = [] def get_queryset(self): - qs_models = {'job_templates': JobTemplate, 'workflow_job_templates': WorkflowJobTemplate} + qs_models = {'job_templates': JobTemplate, 'workflow_job_templates': WorkflowJobTemplate, 'projects': Project} model = qs_models.get(self.kwargs['model_kwarg']) if model is None: raise PermissionDenied @@ -81,8 +84,7 @@ def get_event_guid(self): def get_event_status_api(self): raise NotImplementedError - def get_event_ref(self): - key = self.ref_keys.get(self.get_event_type(), '') + def _get_payload_value(self, key): value = self.request.data for element in key.split('.'): try: @@ -92,10 +94,19 @@ def get_event_ref(self): value = (value or {}).get(element) except Exception: value = None + return value + + def get_event_ref(self): + value = self._get_payload_value(self.ref_keys.get(self.get_event_type(), '')) if value == '0000000000000000000000000000000000000000': # a deleted ref value = None return value + def get_event_ref_name(self): + # The symbolic name of the pushed ref (e.g. refs/heads/main), as opposed + # to the commit hash returned by get_event_ref(). + return self._get_payload_value(self.ref_name_keys.get(self.get_event_type(), '')) + def get_signature(self): raise NotImplementedError @@ -139,6 +150,9 @@ def post(self, request, *args, **kwargs_in): # This was an ignored request type (e.g. ping), don't act on it return Response({'message': _("Webhook ignored")}, status=status.HTTP_200_OK) + if isinstance(obj, Project): + return self.handle_project_sync(obj) + event_type = self.get_event_type() event_guid = self.get_event_guid() event_ref = self.get_event_ref() @@ -172,6 +186,34 @@ def post(self, request, *args, **kwargs_in): return Response({'message': "Job queued."}, status=status.HTTP_202_ACCEPTED) + def handle_project_sync(self, obj): + # For projects the webhook does not carry any variables into a playbook, + # it just triggers the same update the Sync button does, so the project + # is fetched with its configured branch/refspec. + if self.get_event_type() not in self.project_sync_events: + logger.debug("Webhook event type '{}' does not trigger a project sync, ignoring.".format(self.get_event_type())) + return Response({'message': _("Webhook ignored")}, status=status.HTTP_200_OK) + + ref_name = self.get_event_ref_name() + if obj.webhook_ref_filter and not fnmatchcase(ref_name or '', obj.webhook_ref_filter): + logger.debug("Webhook ref '{}' did not match the ref filter of project {}, ignoring.".format(ref_name, obj.id)) + return Response({'message': _("Webhook ref did not match the configured filter, ignoring.")}, status=status.HTTP_200_OK) + + event_guid = self.get_event_guid() + kwargs = {'project_id': obj.id, 'webhook_service': obj.webhook_service, 'webhook_guid': event_guid} + if ProjectUpdate.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({'message': _("Webhook previously received, aborting.")}, status=status.HTTP_202_ACCEPTED) + + if not obj.can_update: + return Response({'message': _("Project cannot be updated.")}, status=status.HTTP_405_METHOD_NOT_ALLOWED) + + project_update = obj.create_project_update(_eager_fields={'launch_type': 'webhook', 'webhook_service': obj.webhook_service, 'webhook_guid': event_guid}) + project_update.signal_start() + + return Response({'message': "Project update queued."}, status=status.HTTP_202_ACCEPTED) + class GithubWebhookReceiver(WebhookReceiverBase): service = 'github' @@ -187,6 +229,9 @@ class GithubWebhookReceiver(WebhookReceiverBase): 'page_build': 'build.commit', } + ref_name_keys = {'push': 'ref'} + project_sync_events = ['push'] + def get_event_type(self): return self.request.headers.get('x-github-event') @@ -215,6 +260,9 @@ class GitlabWebhookReceiver(WebhookReceiverBase): ref_keys = {'Push Hook': 'checkout_sha', 'Tag Push Hook': 'checkout_sha', 'Merge Request Hook': 'object_attributes.last_commit.id'} + ref_name_keys = {'Push Hook': 'ref', 'Tag Push Hook': 'ref'} + project_sync_events = ['Push Hook', 'Tag Push Hook'] + def get_event_type(self): return self.request.headers.get('x-gitlab-event') @@ -259,6 +307,9 @@ class BitbucketDcWebhookReceiver(WebhookReceiverBase): 'pr:modified': 'pullRequest.toRef.latestCommit', } + ref_name_keys = {'repo:refs_changed': 'changes.0.ref.id', 'mirror:repo_synchronized': 'changes.0.ref.id'} + project_sync_events = ['repo:refs_changed', 'mirror:repo_synchronized'] + def get_event_type(self): return self.request.headers.get('x-event-key') diff --git a/awx/main/migrations/0201_project_webhooks.py b/awx/main/migrations/0201_project_webhooks.py new file mode 100644 index 000000000..c2423bf8b --- /dev/null +++ b/awx/main/migrations/0201_project_webhooks.py @@ -0,0 +1,53 @@ +# Generated by Django 5.2.15 on 2026-07-04 10:01 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('main', '0200_add_list_ordering'), + ] + + operations = [ + migrations.AddField( + model_name='project', + name='webhook_key', + field=models.CharField(blank=True, help_text='Shared secret that the webhook service will use to sign requests', max_length=64), + ), + migrations.AddField( + model_name='project', + name='webhook_ref_filter', + field=models.CharField( + blank=True, + default='', + help_text='Only sync the project when the webhook ref matches this fnmatch pattern (e.g. refs/heads/main or refs/heads/release-*). When empty, any push or tag event triggers a sync.', + max_length=1024, + ), + ), + migrations.AddField( + model_name='project', + name='webhook_service', + field=models.CharField( + blank=True, + choices=[('github', 'GitHub'), ('gitlab', 'GitLab'), ('bitbucket_dc', 'BitBucket DataCenter')], + help_text='Service that webhook requests will be accepted from', + max_length=16, + ), + ), + migrations.AddField( + model_name='projectupdate', + name='webhook_guid', + field=models.CharField(blank=True, help_text='Unique identifier of the event that triggered this webhook', max_length=128), + ), + migrations.AddField( + model_name='projectupdate', + name='webhook_service', + field=models.CharField( + blank=True, + choices=[('github', 'GitHub'), ('gitlab', 'GitLab'), ('bitbucket_dc', 'BitBucket DataCenter')], + help_text='Service that the webhook request that triggered this update came from', + max_length=16, + ), + ), + ] diff --git a/awx/main/models/mixins.py b/awx/main/models/mixins.py index a9a81783d..2974d4f06 100644 --- a/awx/main/models/mixins.py +++ b/awx/main/models/mixins.py @@ -546,7 +546,12 @@ def get_active_jobs(self): return [dict(id=t[0], type=mapping[t[1]]) for t in jobs.values_list('id', 'polymorphic_ctype_id')] -class WebhookTemplateMixin(models.Model): +class WebhookKeyTemplateMixin(models.Model): + """ + Webhook service selection and shared secret handling, for any resource + that accepts webhook requests. + """ + class Meta: abstract = True @@ -558,14 +563,6 @@ class Meta: webhook_service = models.CharField(max_length=16, choices=SERVICES, blank=True, help_text=_('Service that webhook requests will be accepted from')) webhook_key = prevent_search(models.CharField(max_length=64, blank=True, help_text=_('Shared secret that the webhook service will use to sign requests'))) - webhook_credential = models.ForeignKey( - 'Credential', - blank=True, - null=True, - on_delete=models.SET_NULL, - related_name='%(class)ss', - help_text=_('Personal Access Token for posting back the status to the service API'), - ) def rotate_webhook_key(self): self.webhook_key = get_random_string(length=50) @@ -585,6 +582,20 @@ def save(self, *args, **kwargs): super().save(*args, **kwargs) +class WebhookTemplateMixin(WebhookKeyTemplateMixin): + class Meta: + abstract = True + + webhook_credential = models.ForeignKey( + 'Credential', + blank=True, + null=True, + on_delete=models.SET_NULL, + related_name='%(class)ss', + help_text=_('Personal Access Token for posting back the status to the service API'), + ) + + class WebhookMixin(models.Model): class Meta: abstract = True diff --git a/awx/main/models/projects.py b/awx/main/models/projects.py index cfa58366f..9c3f490aa 100644 --- a/awx/main/models/projects.py +++ b/awx/main/models/projects.py @@ -31,7 +31,7 @@ UnifiedJobTemplate, ) from awx.main.models.jobs import Job -from awx.main.models.mixins import ResourceMixin, TaskManagerProjectUpdateMixin, RelatedJobsMixin +from awx.main.models.mixins import ResourceMixin, TaskManagerProjectUpdateMixin, RelatedJobsMixin, WebhookKeyTemplateMixin from awx.main.utils import update_scm_url, polymorphic from awx.main.utils.ansible import skip_directory, could_be_inventory, could_be_playbook from awx.main.utils.execution_environments import get_control_plane_execution_environment @@ -248,7 +248,7 @@ def get_lock_file(self): return proj_path + '.lock' -class Project(UnifiedJobTemplate, ProjectOptions, ResourceMixin, RelatedJobsMixin): +class Project(UnifiedJobTemplate, ProjectOptions, ResourceMixin, RelatedJobsMixin, WebhookKeyTemplateMixin): """ A project represents a playbook git repo that can access a set of inventories """ @@ -284,6 +284,15 @@ class Meta: default=False, help_text=_('Allow changing the SCM branch or revision in a job template that uses this project.'), ) + webhook_ref_filter = models.CharField( + max_length=1024, + blank=True, + default='', + help_text=_( + 'Only sync the project when the webhook ref matches this fnmatch pattern (e.g. refs/heads/main or refs/heads/release-*). ' + 'When empty, any push or tag event triggers a sync.' + ), + ) # credential (keys) used to validate content signature signature_validation_credential = models.ForeignKey( @@ -547,6 +556,17 @@ class Meta: verbose_name=_('SCM Revision'), help_text=_('The SCM Revision discovered by this update for the given project and branch.'), ) + webhook_service = models.CharField( + max_length=16, + choices=WebhookKeyTemplateMixin.SERVICES, + blank=True, + help_text=_('Service that the webhook request that triggered this update came from'), + ) + webhook_guid = models.CharField( + blank=True, + max_length=128, + help_text=_('Unique identifier of the event that triggered this webhook'), + ) def _set_default_dependencies_processed(self): self.dependencies_processed = True diff --git a/awx/main/tests/functional/api/test_webhooks.py b/awx/main/tests/functional/api/test_webhooks.py index e47f4fe15..b1ee09285 100644 --- a/awx/main/tests/functional/api/test_webhooks.py +++ b/awx/main/tests/functional/api/test_webhooks.py @@ -1,8 +1,16 @@ +from hashlib import sha1 +import hmac +import json +from unittest import mock + import pytest +from django.utils.encoding import force_bytes + from awx.api.versioning import reverse from awx.main.models.mixins import WebhookTemplateMixin from awx.main.models.credential import Credential, CredentialType +from awx.main.models.projects import ProjectUpdate @pytest.mark.django_db @@ -236,3 +244,201 @@ def test_unset_webhook_service_with_credential(organization_factory, job_templat assert jt.webhook_key != '' assert jt.webhook_credential == cred assert response.data == {'webhook_credential': ["Must match the selected webhook service."]} + + +@pytest.fixture +def github_project(project): + project.webhook_service = 'github' + project.save() + return project + + +@pytest.fixture +def gitlab_project(project): + project.webhook_service = 'gitlab' + project.save() + return project + + +def github_webhook_post(post, project, payload, event='push', guid='some-guid', key=None): + body = json.dumps(payload) + signature = 'sha1={}'.format(hmac.new(force_bytes(key or project.webhook_key), msg=force_bytes(body), digestmod=sha1).hexdigest()) + url = reverse('api:webhook_receiver_github', kwargs={'model_kwarg': 'projects', 'pk': project.pk}) + return post( + url, + data=body, + content_type='application/json', + HTTP_X_GITHUB_EVENT=event, + HTTP_X_GITHUB_DELIVERY=guid, + HTTP_X_HUB_SIGNATURE=signature, + ) + + +@pytest.mark.django_db +@pytest.mark.parametrize( + "user_fixture, role, expect", + [ + ('admin', None, 200), + ('org_admin', None, 200), + ('alice', 'admin_role', 200), + ('alice', 'use_role', 403), + ('org_member', None, 403), + ], +) +def test_get_webhook_key_project(request, project, get, user_fixture, role, expect): + user = request.getfixturevalue(user_fixture) + if role: + getattr(project, role).members.add(user) + + url = reverse('api:webhook_key', kwargs={'model_kwarg': 'projects', 'pk': project.pk}) + response = get(url, user=user, expect=expect) + if expect < 400: + assert response.data == {'webhook_key': ''} + + +@pytest.mark.django_db +@pytest.mark.parametrize( + "user_fixture, role, expect", + [ + ('admin', None, 201), + ('org_admin', None, 201), + ('alice', 'admin_role', 201), + ('alice', 'use_role', 403), + ('org_member', None, 403), + ], +) +def test_post_webhook_key_project(request, project, post, user_fixture, role, expect): + user = request.getfixturevalue(user_fixture) + if role: + getattr(project, role).members.add(user) + + url = reverse('api:webhook_key', kwargs={'model_kwarg': 'projects', 'pk': project.pk}) + 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 WebhookTemplateMixin.SERVICES]) +def test_set_webhook_service_project(project, patch, admin, service): + assert (project.webhook_service, project.webhook_key) == ('', '') + + url = reverse('api:project_detail', kwargs={'pk': project.pk}) + patch(url, {'webhook_service': service}, user=admin, expect=200) + project.refresh_from_db() + + assert project.webhook_service == service + assert project.webhook_key != '' + + +@pytest.mark.django_db +def test_unset_webhook_service_project(github_project, patch, admin): + assert github_project.webhook_key != '' + + url = reverse('api:project_detail', kwargs={'pk': github_project.pk}) + patch(url, {'webhook_service': ''}, user=admin, expect=200) + github_project.refresh_from_db() + + assert (github_project.webhook_service, github_project.webhook_key) == ('', '') + + +@pytest.mark.django_db +def test_set_webhook_service_manual_project(manual_project, patch, admin): + url = reverse('api:project_detail', kwargs={'pk': manual_project.pk}) + response = patch(url, {'webhook_service': 'github'}, user=admin, expect=400) + + assert response.data == {'webhook_service': ["Webhooks are not supported for manual projects."]} + + +@pytest.mark.django_db +def test_github_push_triggers_project_update(github_project, post): + with mock.patch.object(ProjectUpdate, 'signal_start') as signal_start: + response = github_webhook_post(post, github_project, {'ref': 'refs/heads/main', 'after': 'abc123'}) + + assert response.status_code == 202 + signal_start.assert_called_once() + project_update = ProjectUpdate.objects.get(project=github_project, launch_type='webhook') + assert project_update.webhook_service == 'github' + assert project_update.webhook_guid == 'some-guid' + + +@pytest.mark.django_db +def test_github_push_deduplicates_by_guid(github_project, post): + with mock.patch.object(ProjectUpdate, 'signal_start') as signal_start: + first = github_webhook_post(post, github_project, {'ref': 'refs/heads/main', 'after': 'abc123'}, guid='guid-1') + second = github_webhook_post(post, github_project, {'ref': 'refs/heads/main', 'after': 'abc123'}, guid='guid-1') + + assert first.status_code == 202 + assert second.status_code == 202 + signal_start.assert_called_once() + assert ProjectUpdate.objects.filter(project=github_project, launch_type='webhook').count() == 1 + + +@pytest.mark.django_db +def test_github_non_push_event_is_ignored(github_project, post): + response = github_webhook_post(post, github_project, {'action': 'opened'}, event='pull_request') + + assert response.status_code == 200 + assert not ProjectUpdate.objects.filter(project=github_project).exists() + + +@pytest.mark.django_db +def test_github_bad_signature_is_rejected(github_project, post): + response = github_webhook_post(post, github_project, {'ref': 'refs/heads/main'}, key='wrong-key') + + assert response.status_code == 403 + assert not ProjectUpdate.objects.filter(project=github_project).exists() + + +@pytest.mark.django_db +@pytest.mark.parametrize( + "ref_filter, ref, expect_sync", + [ + ('', 'refs/heads/dev', True), + ('refs/heads/main', 'refs/heads/main', True), + ('refs/heads/main', 'refs/heads/dev', False), + ('refs/heads/release-*', 'refs/heads/release-1.2', True), + ('refs/heads/release-*', 'refs/tags/v1.2', False), + ], +) +def test_github_push_ref_filter(github_project, post, ref_filter, ref, expect_sync): + github_project.webhook_ref_filter = ref_filter + github_project.save() + + with mock.patch.object(ProjectUpdate, 'signal_start'): + response = github_webhook_post(post, github_project, {'ref': ref, 'after': 'abc123'}) + + assert response.status_code == (202 if expect_sync else 200) + assert ProjectUpdate.objects.filter(project=github_project, launch_type='webhook').exists() is expect_sync + + +@pytest.mark.django_db +@pytest.mark.parametrize("event, expect_sync", [('Push Hook', True), ('Tag Push Hook', True), ('Merge Request Hook', False)]) +def test_gitlab_events_project_sync(gitlab_project, post, event, expect_sync): + url = reverse('api:webhook_receiver_gitlab', kwargs={'model_kwarg': 'projects', 'pk': gitlab_project.pk}) + with mock.patch.object(ProjectUpdate, 'signal_start'): + response = post( + url, + data=json.dumps({'ref': 'refs/heads/main', 'checkout_sha': 'abc123'}), + content_type='application/json', + HTTP_X_GITLAB_EVENT=event, + HTTP_X_GITLAB_TOKEN=gitlab_project.webhook_key, + ) + + assert response.status_code == (202 if expect_sync else 200) + assert ProjectUpdate.objects.filter(project=gitlab_project, launch_type='webhook').exists() is expect_sync + + +@pytest.mark.django_db +def test_gitlab_bad_token_is_rejected(gitlab_project, post): + url = reverse('api:webhook_receiver_gitlab', kwargs={'model_kwarg': 'projects', 'pk': gitlab_project.pk}) + response = post( + url, + data=json.dumps({'ref': 'refs/heads/main', 'checkout_sha': 'abc123'}), + content_type='application/json', + HTTP_X_GITLAB_EVENT='Push Hook', + HTTP_X_GITLAB_TOKEN='wrong-token', + ) + + assert response.status_code == 403 + assert not ProjectUpdate.objects.filter(project=gitlab_project).exists() diff --git a/awx/ui/src/api/models/Projects.js b/awx/ui/src/api/models/Projects.js index 437da8cac..bed671d07 100644 --- a/awx/ui/src/api/models/Projects.js +++ b/awx/ui/src/api/models/Projects.js @@ -39,9 +39,17 @@ class Projects extends SchedulesMixin( return this.http.get(`${this.baseUrl}${id}/update/`); } + readWebhookKey(id) { + return this.http.get(`${this.baseUrl}${id}/webhook_key/`); + } + sync(id) { return this.http.post(`${this.baseUrl}${id}/update/`); } + + updateWebhookKey(id) { + return this.http.post(`${this.baseUrl}${id}/webhook_key/`); + } } export default Projects; diff --git a/awx/ui/src/screens/Project/Project.js b/awx/ui/src/screens/Project/Project.js index cb64b6122..a6ba9f113 100644 --- a/awx/ui/src/screens/Project/Project.js +++ b/awx/ui/src/screens/Project/Project.js @@ -54,6 +54,18 @@ function Project({ setBreadcrumb }) { data.summary_fields.credentials = results; } + + if ( + data.webhook_service && + data.related?.webhook_key && + data.summary_fields?.user_capabilities?.edit + ) { + const { + data: { webhook_key }, + } = await ProjectsAPI.readWebhookKey(id); + + data.webhook_key = webhook_key; + } return { project: data, isNotifAdmin: notifAdminRes.data.results.length > 0, diff --git a/awx/ui/src/screens/Project/ProjectAdd/ProjectAdd.js b/awx/ui/src/screens/Project/ProjectAdd/ProjectAdd.js index 1e7e2c1ac..e080af3a3 100644 --- a/awx/ui/src/screens/Project/ProjectAdd/ProjectAdd.js +++ b/awx/ui/src/screens/Project/ProjectAdd/ProjectAdd.js @@ -9,7 +9,12 @@ function ProjectAdd() { const [formSubmitError, setFormSubmitError] = useState(null); const navigate = useNavigate(); - const handleSubmit = async (values) => { + const handleSubmit = async ({ + webhook_key, + webhook_url, + webhook_credential, + ...values + }) => { if (values.scm_type === 'manual') { values.scm_type = ''; } diff --git a/awx/ui/src/screens/Project/ProjectDetail/ProjectDetail.js b/awx/ui/src/screens/Project/ProjectDetail/ProjectDetail.js index b597ab884..54be9a308 100644 --- a/awx/ui/src/screens/Project/ProjectDetail/ProjectDetail.js +++ b/awx/ui/src/screens/Project/ProjectDetail/ProjectDetail.js @@ -57,6 +57,8 @@ function ProjectDetail({ project }) { scm_update_cache_timeout, scm_url, summary_fields, + webhook_service, + webhook_ref_filter, } = useWsProject(project); const docsURL = `${getDocsBaseUrl( useConfig() @@ -271,6 +273,33 @@ function ProjectDetail({ project }) { label={t`Cache Timeout`} value={`${scm_update_cache_timeout} ${t`Seconds`}`} /> + {webhook_service && ( + + )} + {project.related?.webhook_receiver && ( + + )} + { + const handleSubmit = async ({ + webhook_key, + webhook_url, + webhook_credential, + ...values + }) => { if (values.scm_type === 'manual') { values.scm_type = ''; } diff --git a/awx/ui/src/screens/Project/shared/Project.helptext.js b/awx/ui/src/screens/Project/shared/Project.helptext.js index db01d8cff..37c799edb 100644 --- a/awx/ui/src/screens/Project/shared/Project.helptext.js +++ b/awx/ui/src/screens/Project/shared/Project.helptext.js @@ -133,7 +133,18 @@ job will not run.`, update. If it is older than Cache Timeout, it is not considered current, and a new project update will be performed.`, + enableWebhook: t`Sync the project when a push happens in the + source control repository, so the local copy is + always up to date without polling or updating on + every job launch.`, }, + webhookService: t`Service that webhook requests will be accepted from.`, + webhookURL: t`The webhook endpoint of this project. Add it to the + webhook configuration of the repository to have pushes + trigger a project sync.`, + webhookKey: t`Secret shared with the webhook service. The service uses + it to sign its requests, so only your repository can + trigger a project sync.`, }); export default getProjectHelpText; diff --git a/awx/ui/src/screens/Project/shared/ProjectForm.js b/awx/ui/src/screens/Project/shared/ProjectForm.js index d8009c700..d12d3ac44 100644 --- a/awx/ui/src/screens/Project/shared/ProjectForm.js +++ b/awx/ui/src/screens/Project/shared/ProjectForm.js @@ -450,6 +450,13 @@ function ProjectForm({ project = {}, submitError = null, ...props }) { project.signature_validation_credential || '', default_environment: project.summary_fields?.default_environment || null, + webhook_service: project.webhook_service || '', + webhook_url: project?.related?.webhook_receiver + ? `${document.location.origin}${project.related.webhook_receiver}` + : '', + webhook_key: + project.webhook_key || 'A NEW WEBHOOK KEY WILL BE GENERATED ON SAVE.', + webhook_ref_filter: project.webhook_ref_filter || '', }} onSubmit={handleSubmit} > diff --git a/awx/ui/src/screens/Project/shared/ProjectForm.test.js b/awx/ui/src/screens/Project/shared/ProjectForm.test.js index e0ba720a6..379fa6763 100644 --- a/awx/ui/src/screens/Project/shared/ProjectForm.test.js +++ b/awx/ui/src/screens/Project/shared/ProjectForm.test.js @@ -157,6 +157,52 @@ describe('', () => { ).toBeInTheDocument(); }); + test('git project with a webhook service mounts with the webhook subform open', async () => { + renderWithContexts( + + ); + await screen.findByText('Source Control Type'); + + expect( + screen.getByRole('checkbox', { name: 'Enable Webhook' }) + ).toBeChecked(); + expect(await screen.findByText('Webhook details')).toBeInTheDocument(); + expect(screen.getByText('Webhook Ref Filter')).toBeInTheDocument(); + // projects have no webhook credential + expect(screen.queryByText('Webhook Credential')).not.toBeInTheDocument(); + }); + + test('checking Enable Webhook reveals the webhook subform', async () => { + const { user } = renderWithContexts( + + ); + await screen.findByText('Source Control URL'); + + const webhookCheckbox = screen.getByRole('checkbox', { + name: 'Enable Webhook', + }); + expect(webhookCheckbox).not.toBeChecked(); + expect(screen.queryByText('Webhook details')).not.toBeInTheDocument(); + + await user.click(webhookCheckbox); + + expect(await screen.findByText('Webhook details')).toBeInTheDocument(); + }); + test('manual subform should display expected fields', async () => { const config = { project_local_paths: ['foobar', 'qux'], diff --git a/awx/ui/src/screens/Project/shared/ProjectSubForms/SharedFields.js b/awx/ui/src/screens/Project/shared/ProjectSubForms/SharedFields.js index eccbff2c5..25217632a 100644 --- a/awx/ui/src/screens/Project/shared/ProjectSubForms/SharedFields.js +++ b/awx/ui/src/screens/Project/shared/ProjectSubForms/SharedFields.js @@ -1,11 +1,17 @@ -import React, { useCallback } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; import { useLingui } from '@lingui/react/macro'; -import { useFormikContext } from 'formik'; -import { FormGroup, Title } from '@patternfly/react-core'; +import { useField, useFormikContext } from 'formik'; +import { Checkbox, FormGroup, Title } from '@patternfly/react-core'; import CredentialLookup from 'components/Lookup/CredentialLookup'; import FormField, { CheckboxField } from 'components/FormField'; +import Popover from 'components/Popover'; import { required } from 'util/validators'; -import { FormCheckboxLayout, FormFullWidthLayout } from 'components/FormLayout'; +import { + FormCheckboxLayout, + FormColumnLayout, + FormFullWidthLayout, +} from 'components/FormLayout'; +import WebhookSubForm from '../../../Template/shared/WebhookSubForm'; import getProjectHelpStrings from '../Project.helptext'; export const UrlFormField = ({ tooltip }) => { @@ -69,6 +75,31 @@ export const ScmTypeOptions = ({ scmUpdateOnLaunch, hideAllowOverride }) => { const { values } = useFormikContext(); const projectHelpStrings = getProjectHelpStrings(t); + const [enableWebhooks, setEnableWebhooks] = useState( + Boolean(values.webhook_service) + ); + const [, webhookServiceMeta, webhookServiceHelpers] = + useField('webhook_service'); + const [, webhookUrlMeta, webhookUrlHelpers] = useField('webhook_url'); + const [, webhookKeyMeta, webhookKeyHelpers] = useField('webhook_key'); + const [, webhookRefFilterMeta, webhookRefFilterHelpers] = + useField('webhook_ref_filter'); + + useEffect(() => { + if (enableWebhooks) { + webhookServiceHelpers.setValue(webhookServiceMeta.initialValue); + webhookUrlHelpers.setValue(webhookUrlMeta.initialValue); + webhookKeyHelpers.setValue(webhookKeyMeta.initialValue); + webhookRefFilterHelpers.setValue(webhookRefFilterMeta.initialValue); + } else { + webhookServiceHelpers.setValue(''); + webhookUrlHelpers.setValue(''); + webhookKeyHelpers.setValue(''); + webhookRefFilterHelpers.setValue(''); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [enableWebhooks]); + return ( { tooltip={projectHelpStrings.options.allowBranchOverride} /> )} + + {t`Enable Webhook`} +   + + + } + id="option-enable-webhook" + ouiaId="option-enable-webhook" + isChecked={enableWebhooks} + onChange={(_event, checked) => { + setEnableWebhooks(checked); + }} + /> @@ -128,6 +175,17 @@ export const ScmTypeOptions = ({ scmUpdateOnLaunch, hideAllowOverride }) => { /> )} + + {enableWebhooks && ( + <> + + {t`Webhook details`} + + + + + + )} ); }; diff --git a/awx/ui/src/screens/Template/shared/WebhookSubForm.js b/awx/ui/src/screens/Template/shared/WebhookSubForm.js index aa766d660..f7695462a 100644 --- a/awx/ui/src/screens/Template/shared/WebhookSubForm.js +++ b/awx/ui/src/screens/Template/shared/WebhookSubForm.js @@ -17,24 +17,30 @@ import { useField, useFormikContext } from 'formik'; import ContentError from 'components/ContentError'; import ContentLoading from 'components/ContentLoading'; import useRequest from 'hooks/useRequest'; +import FormField from 'components/FormField'; import { FormColumnLayout } from 'components/FormLayout'; import { CredentialLookup } from 'components/Lookup'; import AnsibleSelect from 'components/AnsibleSelect'; import Popover from 'components/Popover'; import { JobTemplatesAPI, + ProjectsAPI, WorkflowJobTemplatesAPI, CredentialTypesAPI, } from 'api'; +import getProjectHelpText from '../../Project/shared/Project.helptext'; import getHelpText from './WorkflowJobTemplate.helptext'; function WebhookSubForm({ templateType }) { const { t } = useLingui(); - const helpText = getHelpText(t); const { setFieldValue } = useFormikContext(); const { id } = useParams(); const { pathname } = useLocation(); const { origin } = document.location; + // Projects use the webhook to trigger an SCM update, so there is no + // credential to post job statuses back with and no payload variables. + const isProject = templateType === 'project'; + const helpText = isProject ? getProjectHelpText(t) : getHelpText(t); const [webhookServiceField, webhookServiceMeta, webhookServiceHelpers] = useField('webhook_service'); @@ -55,13 +61,13 @@ function WebhookSubForm({ templateType }) { } = useRequest( useCallback(async () => { let results; - if (webhookServiceField.value) { + if (webhookServiceField.value && !isProject) { results = await CredentialTypesAPI.read({ namespace: `${webhookServiceField.value}_token`, }); } return results?.data?.results[0]?.id; - }, [webhookServiceField.value]) + }, [webhookServiceField.value, isProject]) ); useEffect(() => { @@ -70,13 +76,15 @@ function WebhookSubForm({ templateType }) { const { request: fetchWebhookKey, error: webhookKeyError } = useRequest( useCallback(async () => { - const updateWebhookKey = - templateType === 'job_template' - ? JobTemplatesAPI.updateWebhookKey(id) - : WorkflowJobTemplatesAPI.updateWebhookKey(id); + const webhookKeyAPIs = { + job_template: JobTemplatesAPI, + project: ProjectsAPI, + }; + const webhookKeyAPI = + webhookKeyAPIs[templateType] || WorkflowJobTemplatesAPI; const { data: { webhook_key: key }, - } = await updateWebhookKey; + } = await webhookKeyAPI.updateWebhookKey(id); webhookKeyHelpers.setValue(key); }, [webhookKeyHelpers, id, templateType]) ); @@ -211,7 +219,17 @@ function WebhookSubForm({ templateType }) { - {credTypeId && ( + {isProject && ( + + )} + + {!isProject && credTypeId && ( )} - {!credTypeId && !isLoading && webhookServiceField.value && ( + {!isProject && !credTypeId && !isLoading && webhookServiceField.value && ( ', () => { ).toBeInTheDocument(); expect(screen.queryByText('Webhook Credential')).not.toBeInTheDocument(); }); + + describe('project webhooks', () => { + const projectInitialValues = { + webhook_url: '/api/v2/projects/7/github/', + webhook_service: 'github', + webhook_key: 'webhook key', + webhook_ref_filter: '', + }; + + const renderProjectForm = (values) => { + history = createMemoryHistory({ initialEntries: ['/projects/7/edit'] }); + return renderWithContexts( + + + + + } + /> + , + { + context: { + router: { + history, + }, + }, + } + ); + }; + + test('should render ref filter and skip the credential lookup', async () => { + renderProjectForm(projectInitialValues); + + expect(await screen.findByLabelText('Select Input')).toHaveValue( + 'github' + ); + expect(screen.getByLabelText('Webhook URL')).toHaveValue( + '/api/v2/projects/7/github/' + ); + expect(screen.getByText('Webhook Ref Filter')).toBeInTheDocument(); + // projects have no webhook credential, so neither the lookup nor the + // missing credential type warning should render + expect(CredentialTypesAPI.read).not.toHaveBeenCalled(); + expect(screen.queryByText('Webhook Credential')).not.toBeInTheDocument(); + expect( + screen.queryByText( + 'Unable to look up the credential type for this webhook service, so the webhook credential field is unavailable.' + ) + ).not.toBeInTheDocument(); + }); + + test('should rotate the webhook key through the projects API', async () => { + ProjectsAPI.updateWebhookKey.mockResolvedValue({ + data: { webhook_key: 'brandnewkey123' }, + }); + renderProjectForm(projectInitialValues); + + fireEvent.click( + await screen.findByRole('button', { name: 'Update webhook key' }) + ); + await waitFor(() => + expect(ProjectsAPI.updateWebhookKey).toHaveBeenCalledWith('7') + ); + }); + }); }); diff --git a/docs/docsite/rst/userguide/webhooks.rst b/docs/docsite/rst/userguide/webhooks.rst index e6c04f761..d6b7da286 100644 --- a/docs/docsite/rst/userguide/webhooks.rst +++ b/docs/docsite/rst/userguide/webhooks.rst @@ -189,3 +189,18 @@ The entire payload is exposed as an extra variable. To view the payload informat .. image:: ../common/images/webhooks-jobs-extra-vars-payload-expanded.png :alt: Variables field expanded view + + +Project webhooks +----------------- + +.. index:: + pair: webhooks; projects + +Projects can also be updated by a webhook, so a push to the repository immediately syncs the project instead of relying on scheduled updates or on **Update Revision on Launch** (which delays every job launch by a project update). + +To enable it, edit the project, check **Enable Webhook** in the options of the source control type, and pick the webhook service (GitHub, GitLab or Bitbucket Data Center). Saving the project generates the **Webhook URL** and **Webhook Key**; configure them in the repository the same way as for a job template (see the sections above), selecting only push events. There is no webhook credential for projects, since a project sync has no status to post back. + +Only push and tag push events trigger a sync; any other event is acknowledged and ignored. The sync behaves exactly like the **Sync** button and fetches the branch or refspec configured in the project. If you only care about some refs, set a **Webhook Ref Filter** pattern, for example ``refs/heads/main`` or ``refs/heads/release-*``; pushes to refs that do not match the pattern are ignored. When the filter is empty, any push triggers a sync. + +Project updates started by a webhook show ``webhook`` as their launch type, along with the service and the event id that triggered them. From a06b419929b18a20979420f1a380cf74092d0a79 Mon Sep 17 00:00:00 2001 From: fernandorocagonzalez Date: Sat, 4 Jul 2026 13:03:06 +0200 Subject: [PATCH 2/3] Allow supplying your own webhook key Until now the webhook key could only be generated by the server, which does not play well with configuration as code: every time the resource is re-applied the key changes and the repository webhook has to be updated by hand. The webhook_key field is now also writable (write only) on projects, job templates and workflow job templates. When a key is supplied it is kept as is, so the same secret can be stored in a vault and applied to both the repository and the resource by automation. When the field is left blank the previous behavior remains: a new key is generated whenever the webhook service is set or changed, and blanking the key of an active webhook generates a fresh one. Keys are never returned on the resource itself, reading them still requires the webhook_key endpoint, and copies of a resource always get their own key. The key field in the UI is now an editable input with the same semantics. --- awx/api/serializers.py | 29 ++++++ awx/api/templates/api/webhook_key_view.md | 3 + awx/main/models/jobs.py | 2 +- awx/main/models/mixins.py | 22 ++++- awx/main/models/projects.py | 2 +- awx/main/models/workflow.py | 1 + .../tests/functional/api/test_webhooks.py | 92 +++++++++++++++++++ .../screens/Project/ProjectAdd/ProjectAdd.js | 3 + .../Project/ProjectEdit/ProjectEdit.js | 3 + .../Project/shared/Project.helptext.js | 4 +- .../src/screens/Project/shared/ProjectForm.js | 3 +- .../Template/JobTemplateAdd/JobTemplateAdd.js | 3 + .../JobTemplateEdit/JobTemplateEdit.js | 3 + .../WorkflowJobTemplateAdd.js | 3 + .../WorkflowJobTemplateEdit.js | 3 + .../Template/shared/JobTemplateForm.js | 3 +- .../Template/shared/JobTemplateForm.test.js | 8 +- .../screens/Template/shared/WebhookSubForm.js | 11 +-- .../Template/shared/WebhookSubForm.test.js | 19 +++- .../shared/WorkflowJobTemplateForm.test.js | 2 +- docs/docsite/rst/userguide/webhooks.rst | 2 + 21 files changed, 194 insertions(+), 27 deletions(-) diff --git a/awx/api/serializers.py b/awx/api/serializers.py index 281f6c0c0..d55571d27 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -1499,6 +1499,13 @@ class ProjectSerializer(UnifiedJobTemplateSerializer, ProjectOptionsSerializer): status = serializers.ChoiceField(choices=Project.PROJECT_STATUS_CHOICES, read_only=True) last_update_failed = serializers.BooleanField(read_only=True) last_updated = serializers.DateTimeField(read_only=True) + webhook_key = serializers.CharField( + write_only=True, + required=False, + allow_blank=True, + max_length=64, + help_text=_('Shared secret that the webhook service will use to sign requests. Leave blank to generate a new one when the webhook service is set.'), + ) show_capabilities = ['start', 'schedule', 'edit', 'delete', 'copy'] capabilities_prefetch = ['admin', 'update', {'copy': 'organization.project_admin'}] @@ -1514,6 +1521,7 @@ class Meta: 'default_environment', 'signature_validation_credential', 'webhook_service', + 'webhook_key', 'webhook_ref_filter', ) + ( 'last_update_failed', @@ -1591,6 +1599,8 @@ def get_field_from_model_or_attrs(fd): raise serializers.ValidationError({fd: _('Update options must be set to false for manual projects.')}) if get_field_from_model_or_attrs('webhook_service') and not get_field_from_model_or_attrs('scm_type'): raise serializers.ValidationError({'webhook_service': _('Webhooks are not supported for manual projects.')}) + if attrs.get('webhook_key') and not get_field_from_model_or_attrs('webhook_service'): + raise serializers.ValidationError({'webhook_key': _("Cannot set a webhook key without a webhook service.")}) return super(ProjectSerializer, self).validate(attrs) @@ -3295,6 +3305,9 @@ 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 attrs.get('webhook_key') and not webhook_service: + raise serializers.ValidationError({'webhook_key': _("Cannot set a webhook key without a webhook service.")}) + if webhook_credential: if webhook_credential.credential_type.kind != 'token': raise serializers.ValidationError({'webhook_credential': _("Must be a Personal Access Token.")}) @@ -3314,6 +3327,13 @@ class JobTemplateSerializer(JobTemplateMixin, UnifiedJobTemplateSerializer, JobO capabilities_prefetch = ['admin', 'execute', {'copy': ['project.use', 'inventory.use']}] status = serializers.ChoiceField(choices=JobTemplate.JOB_TEMPLATE_STATUS_CHOICES, read_only=True, required=False) + webhook_key = serializers.CharField( + write_only=True, + required=False, + allow_blank=True, + max_length=64, + help_text=_('Shared secret that the webhook service will use to sign requests. Leave blank to generate a new one when the webhook service is set.'), + ) class Meta: model = JobTemplate @@ -3343,6 +3363,7 @@ class Meta: 'job_slice_count', 'webhook_service', 'webhook_credential', + 'webhook_key', 'prevent_instance_group_fallback', ) read_only_fields = ('*',) @@ -3797,6 +3818,13 @@ class WorkflowJobTemplateSerializer(JobTemplateMixin, LabelsListMixin, UnifiedJo skip_tags = serializers.CharField(allow_blank=True, allow_null=True, required=False, default=None) job_tags = serializers.CharField(allow_blank=True, allow_null=True, required=False, default=None) + webhook_key = serializers.CharField( + write_only=True, + required=False, + allow_blank=True, + max_length=64, + help_text=_('Shared secret that the webhook service will use to sign requests. Leave blank to generate a new one when the webhook service is set.'), + ) class Meta: model = WorkflowJobTemplate @@ -3815,6 +3843,7 @@ class Meta: 'ask_limit_on_launch', 'webhook_service', 'webhook_credential', + 'webhook_key', '-execution_environment', 'ask_labels_on_launch', 'ask_skip_tags_on_launch', diff --git a/awx/api/templates/api/webhook_key_view.md b/awx/api/templates/api/webhook_key_view.md index d5644ec01..12afcc330 100644 --- a/awx/api/templates/api/webhook_key_view.md +++ b/awx/api/templates/api/webhook_key_view.md @@ -10,3 +10,6 @@ by webhook events. The response will include the following fields: Make an empty POST request to this resource to generate a new replacement `webhook_key`. + +A specific key can also be set by writing to the `webhook_key` field +of the job template, workflow job template or project resource itself. diff --git a/awx/main/models/jobs.py b/awx/main/models/jobs.py index 1ef13cb12..7c77c85e2 100644 --- a/awx/main/models/jobs.py +++ b/awx/main/models/jobs.py @@ -235,7 +235,7 @@ class JobTemplate(UnifiedJobTemplate, JobOptions, SurveyJobTemplateMixin, Resour """ FIELDS_TO_PRESERVE_AT_COPY = ['labels', 'instance_groups', 'credentials', 'survey_spec', 'prevent_instance_group_fallback'] - FIELDS_TO_DISCARD_AT_COPY = ['vault_credential', 'credential'] + FIELDS_TO_DISCARD_AT_COPY = ['vault_credential', 'credential', 'webhook_key'] SOFT_UNIQUE_TOGETHER = [('polymorphic_ctype', 'name', 'organization')] class Meta: diff --git a/awx/main/models/mixins.py b/awx/main/models/mixins.py index 2974d4f06..7b3e762fc 100644 --- a/awx/main/models/mixins.py +++ b/awx/main/models/mixins.py @@ -570,14 +570,26 @@ def rotate_webhook_key(self): 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: + if self.pk: + service_edited = self._values_have_edits({'webhook_service': self.webhook_service}) + key_edited = self._values_have_edits({'webhook_key': self.webhook_key}) + else: + service_edited = True + key_edited = bool(self.webhook_key) + + if service_edited: + if not self.webhook_service: self.webhook_key = '' + elif not (key_edited and self.webhook_key): + # No key was supplied by the caller, generate one. A caller + # provided key (e.g. one managed as configuration) is kept as is. + self.rotate_webhook_key() - if update_fields and 'webhook_service' in update_fields: + if update_fields and 'webhook_service' in update_fields and 'webhook_key' not in update_fields: update_fields.add('webhook_key') + elif key_edited and self.webhook_service and not self.webhook_key: + # Blanking the key of an active webhook means please generate a new one. + self.rotate_webhook_key() super().save(*args, **kwargs) diff --git a/awx/main/models/projects.py b/awx/main/models/projects.py index 9c3f490aa..619187adf 100644 --- a/awx/main/models/projects.py +++ b/awx/main/models/projects.py @@ -255,7 +255,7 @@ class Project(UnifiedJobTemplate, ProjectOptions, ResourceMixin, RelatedJobsMixi SOFT_UNIQUE_TOGETHER = [('polymorphic_ctype', 'name', 'organization')] FIELDS_TO_PRESERVE_AT_COPY = ['labels', 'instance_groups', 'credentials'] - FIELDS_TO_DISCARD_AT_COPY = ['local_path'] + FIELDS_TO_DISCARD_AT_COPY = ['local_path', 'webhook_key'] FIELDS_TRIGGER_UPDATE = frozenset(['scm_url', 'scm_branch', 'scm_type', 'scm_refspec']) class Meta: diff --git a/awx/main/models/workflow.py b/awx/main/models/workflow.py index 4cd305562..b624156d7 100644 --- a/awx/main/models/workflow.py +++ b/awx/main/models/workflow.py @@ -733,6 +733,7 @@ class WorkflowJobTemplate(UnifiedJobTemplate, WorkflowJobOptions, SurveyJobTempl 'job_tags', 'execution_environment', ] + FIELDS_TO_DISCARD_AT_COPY = ['webhook_key'] class Meta: app_label = 'main' diff --git a/awx/main/tests/functional/api/test_webhooks.py b/awx/main/tests/functional/api/test_webhooks.py index b1ee09285..73b7f0139 100644 --- a/awx/main/tests/functional/api/test_webhooks.py +++ b/awx/main/tests/functional/api/test_webhooks.py @@ -350,6 +350,98 @@ def test_set_webhook_service_manual_project(manual_project, patch, admin): assert response.data == {'webhook_service': ["Webhooks are not supported for manual projects."]} +@pytest.mark.django_db +@pytest.mark.parametrize( + "model_kwarg, url_name", + [ + ('projects', 'api:project_detail'), + ('job_templates', 'api:job_template_detail'), + ('workflow_job_templates', 'api:workflow_job_template_detail'), + ], +) +def test_set_custom_webhook_key(organization_factory, job_template_factory, workflow_job_template_factory, project, patch, get, admin, model_kwarg, url_name): + objs = organization_factory("org") + if model_kwarg == 'projects': + obj = project + elif model_kwarg == 'job_templates': + obj = job_template_factory("jt", organization=objs.organization, inventory='test_inv', project='test_proj').job_template + else: + obj = workflow_job_template_factory("wfjt", organization=objs.organization).workflow_job_template + + url = reverse(url_name, kwargs={'pk': obj.pk}) + response = patch(url, {'webhook_service': 'github', 'webhook_key': 'secret-managed-as-config'}, user=admin, expect=200) + obj.refresh_from_db() + + assert obj.webhook_service == 'github' + assert obj.webhook_key == 'secret-managed-as-config' + # the key is write only, it can only be read back through the webhook_key endpoint + assert 'webhook_key' not in response.data + + key_url = reverse('api:webhook_key', kwargs={'model_kwarg': model_kwarg, 'pk': obj.pk}) + response = get(key_url, user=admin, expect=200) + assert response.data == {'webhook_key': 'secret-managed-as-config'} + + +@pytest.mark.django_db +def test_change_webhook_key_keeps_service(github_project, patch, admin): + old_key = github_project.webhook_key + + url = reverse('api:project_detail', kwargs={'pk': github_project.pk}) + patch(url, {'webhook_key': 'new-secret'}, user=admin, expect=200) + github_project.refresh_from_db() + + assert github_project.webhook_service == 'github' + assert github_project.webhook_key == 'new-secret' + assert github_project.webhook_key != old_key + + +@pytest.mark.django_db +def test_blank_webhook_key_generates_new_one(github_project, patch, admin): + old_key = github_project.webhook_key + + url = reverse('api:project_detail', kwargs={'pk': github_project.pk}) + patch(url, {'webhook_key': ''}, user=admin, expect=200) + github_project.refresh_from_db() + + assert github_project.webhook_key != '' + assert github_project.webhook_key != old_key + + +@pytest.mark.django_db +def test_webhook_service_change_rotates_key_unless_key_given(github_project, patch, admin): + old_key = github_project.webhook_key + + url = reverse('api:project_detail', kwargs={'pk': github_project.pk}) + patch(url, {'webhook_service': 'gitlab'}, user=admin, expect=200) + github_project.refresh_from_db() + assert github_project.webhook_key not in ('', old_key) + + patch(url, {'webhook_service': 'github', 'webhook_key': 'pinned-secret'}, user=admin, expect=200) + github_project.refresh_from_db() + assert (github_project.webhook_service, github_project.webhook_key) == ('github', 'pinned-secret') + + +@pytest.mark.django_db +def test_webhook_key_requires_service(project, patch, admin): + url = reverse('api:project_detail', kwargs={'pk': project.pk}) + response = patch(url, {'webhook_key': 'orphan-secret'}, user=admin, expect=400) + + assert response.data == {'webhook_key': ["Cannot set a webhook key without a webhook service."]} + + +@pytest.mark.django_db +def test_copied_project_gets_its_own_webhook_key(github_project, post, admin): + url = reverse('api:project_copy', kwargs={'pk': github_project.pk}) + response = post(url, {'name': 'copied-project'}, user=admin, expect=201) + + from awx.main.models.projects import Project + + copied = Project.objects.get(pk=response.data['id']) + assert copied.webhook_service == 'github' + assert copied.webhook_key != '' + assert copied.webhook_key != github_project.webhook_key + + @pytest.mark.django_db def test_github_push_triggers_project_update(github_project, post): with mock.patch.object(ProjectUpdate, 'signal_start') as signal_start: diff --git a/awx/ui/src/screens/Project/ProjectAdd/ProjectAdd.js b/awx/ui/src/screens/Project/ProjectAdd/ProjectAdd.js index e080af3a3..770bd4051 100644 --- a/awx/ui/src/screens/Project/ProjectAdd/ProjectAdd.js +++ b/awx/ui/src/screens/Project/ProjectAdd/ProjectAdd.js @@ -33,6 +33,9 @@ function ProjectAdd() { values.signature_validation_credential = values.signature_validation_credential.id; } + if (webhook_key) { + values.webhook_key = webhook_key; + } setFormSubmitError(null); try { const { diff --git a/awx/ui/src/screens/Project/ProjectEdit/ProjectEdit.js b/awx/ui/src/screens/Project/ProjectEdit/ProjectEdit.js index 7349f6d48..9065f2029 100644 --- a/awx/ui/src/screens/Project/ProjectEdit/ProjectEdit.js +++ b/awx/ui/src/screens/Project/ProjectEdit/ProjectEdit.js @@ -34,6 +34,9 @@ function ProjectEdit({ project }) { values.signature_validation_credential.id; } + if (webhook_key) { + values.webhook_key = webhook_key; + } try { const { data: { id }, diff --git a/awx/ui/src/screens/Project/shared/Project.helptext.js b/awx/ui/src/screens/Project/shared/Project.helptext.js index 37c799edb..3a4b57d84 100644 --- a/awx/ui/src/screens/Project/shared/Project.helptext.js +++ b/awx/ui/src/screens/Project/shared/Project.helptext.js @@ -144,7 +144,9 @@ job will not run.`, trigger a project sync.`, webhookKey: t`Secret shared with the webhook service. The service uses it to sign its requests, so only your repository can - trigger a project sync.`, + trigger a project sync. Type your own secret to manage it + as configuration, or leave the field blank to have one + generated on save.`, }); export default getProjectHelpText; diff --git a/awx/ui/src/screens/Project/shared/ProjectForm.js b/awx/ui/src/screens/Project/shared/ProjectForm.js index d12d3ac44..d40dcdba4 100644 --- a/awx/ui/src/screens/Project/shared/ProjectForm.js +++ b/awx/ui/src/screens/Project/shared/ProjectForm.js @@ -454,8 +454,7 @@ function ProjectForm({ project = {}, submitError = null, ...props }) { webhook_url: project?.related?.webhook_receiver ? `${document.location.origin}${project.related.webhook_receiver}` : '', - webhook_key: - project.webhook_key || 'A NEW WEBHOOK KEY WILL BE GENERATED ON SAVE.', + webhook_key: project.webhook_key || '', webhook_ref_filter: project.webhook_ref_filter || '', }} onSubmit={handleSubmit} diff --git a/awx/ui/src/screens/Template/JobTemplateAdd/JobTemplateAdd.js b/awx/ui/src/screens/Template/JobTemplateAdd/JobTemplateAdd.js index e55ee8355..888c42a1b 100644 --- a/awx/ui/src/screens/Template/JobTemplateAdd/JobTemplateAdd.js +++ b/awx/ui/src/screens/Template/JobTemplateAdd/JobTemplateAdd.js @@ -58,6 +58,9 @@ function JobTemplateAdd() { setFormSubmitError(null); remainingValues.project = project.id; remainingValues.webhook_credential = webhook_credential?.id; + if (webhook_key) { + remainingValues.webhook_key = webhook_key; + } remainingValues.inventory = inventory?.id || null; try { const { diff --git a/awx/ui/src/screens/Template/JobTemplateEdit/JobTemplateEdit.js b/awx/ui/src/screens/Template/JobTemplateEdit/JobTemplateEdit.js index 8be205287..505c9ae35 100644 --- a/awx/ui/src/screens/Template/JobTemplateEdit/JobTemplateEdit.js +++ b/awx/ui/src/screens/Template/JobTemplateEdit/JobTemplateEdit.js @@ -54,6 +54,9 @@ function JobTemplateEdit({ template, reloadTemplate }) { setIsLoading(true); remainingValues.project = project.id; remainingValues.webhook_credential = webhook_credential?.id || null; + if (webhook_key) { + remainingValues.webhook_key = webhook_key; + } remainingValues.inventory = inventory?.id || null; remainingValues.execution_environment = execution_environment?.id || null; try { diff --git a/awx/ui/src/screens/Template/WorkflowJobTemplateAdd/WorkflowJobTemplateAdd.js b/awx/ui/src/screens/Template/WorkflowJobTemplateAdd/WorkflowJobTemplateAdd.js index 21e245475..4c6693ccc 100644 --- a/awx/ui/src/screens/Template/WorkflowJobTemplateAdd/WorkflowJobTemplateAdd.js +++ b/awx/ui/src/screens/Template/WorkflowJobTemplateAdd/WorkflowJobTemplateAdd.js @@ -30,6 +30,9 @@ function WorkflowJobTemplateAdd() { templatePayload.inventory = inventory?.id; templatePayload.organization = organization?.id; templatePayload.webhook_credential = webhook_credential?.id; + if (webhook_key) { + templatePayload.webhook_key = webhook_key; + } templatePayload.limit = limit === '' ? null : limit; templatePayload.job_tags = job_tags === '' ? null : job_tags; templatePayload.skip_tags = skip_tags === '' ? null : skip_tags; diff --git a/awx/ui/src/screens/Template/WorkflowJobTemplateEdit/WorkflowJobTemplateEdit.js b/awx/ui/src/screens/Template/WorkflowJobTemplateEdit/WorkflowJobTemplateEdit.js index 57d2b1c4f..d9f9d3846 100644 --- a/awx/ui/src/screens/Template/WorkflowJobTemplateEdit/WorkflowJobTemplateEdit.js +++ b/awx/ui/src/screens/Template/WorkflowJobTemplateEdit/WorkflowJobTemplateEdit.js @@ -36,6 +36,9 @@ function WorkflowJobTemplateEdit({ template }) { templatePayload.inventory = inventory?.id || null; templatePayload.organization = organization?.id || null; templatePayload.webhook_credential = webhook_credential?.id || null; + if (webhook_key) { + templatePayload.webhook_key = webhook_key; + } templatePayload.limit = limit === '' ? null : limit; templatePayload.job_tags = job_tags === '' ? null : job_tags; templatePayload.skip_tags = skip_tags === '' ? null : skip_tags; diff --git a/awx/ui/src/screens/Template/shared/JobTemplateForm.js b/awx/ui/src/screens/Template/shared/JobTemplateForm.js index 4d03750aa..080a6a620 100644 --- a/awx/ui/src/screens/Template/shared/JobTemplateForm.js +++ b/awx/ui/src/screens/Template/shared/JobTemplateForm.js @@ -750,8 +750,7 @@ const FormikApp = withFormik({ webhook_url: template?.related?.webhook_receiver ? `${origin}${template.related.webhook_receiver}` : 'A NEW WEBHOOK URL WILL BE GENERATED ON SAVE.', - webhook_key: - template.webhook_key || 'A NEW WEBHOOK KEY WILL BE GENERATED ON SAVE.', + webhook_key: template.webhook_key || '', webhook_credential: template?.summary_fields?.webhook_credential || null, execution_environment: template.summary_fields?.execution_environment || null, diff --git a/awx/ui/src/screens/Template/shared/JobTemplateForm.test.js b/awx/ui/src/screens/Template/shared/JobTemplateForm.test.js index 9734cfa93..c235b4035 100644 --- a/awx/ui/src/screens/Template/shared/JobTemplateForm.test.js +++ b/awx/ui/src/screens/Template/shared/JobTemplateForm.test.js @@ -325,7 +325,7 @@ describe('', () => { const webhookKeyInput = await screen.findByLabelText( 'workflow job template webhook key' ); - expect(webhookKeyInput).toHaveAttribute('readonly'); + expect(webhookKeyInput).not.toHaveAttribute('readonly'); expect(webhookKeyInput).toHaveValue('webhook key'); fireEvent.click(screen.getByRole('button', { name: 'Update webhook key' })); @@ -372,9 +372,9 @@ describe('', () => { const webhookKeyInput = await screen.findByLabelText( 'workflow job template webhook key' ); - expect(webhookKeyInput).toHaveValue( - 'A NEW WEBHOOK KEY WILL BE GENERATED ON SAVE.' - ); + // no key yet: the field is empty (a new key is generated on save unless + // the user types their own) and there is nothing to rotate. + expect(webhookKeyInput).toHaveValue(''); expect( screen.getByRole('button', { name: 'Update webhook key' }) ).toBeDisabled(); diff --git a/awx/ui/src/screens/Template/shared/WebhookSubForm.js b/awx/ui/src/screens/Template/shared/WebhookSubForm.js index f7695462a..78bfc9e5f 100644 --- a/awx/ui/src/screens/Template/shared/WebhookSubForm.js +++ b/awx/ui/src/screens/Template/shared/WebhookSubForm.js @@ -101,9 +101,7 @@ function WebhookSubForm({ templateType }) { ); const isUpdateKeyDisabled = - pathname.endsWith('/add') || - webhookKeyMeta.initialValue === - 'A NEW WEBHOOK KEY WILL BE GENERATED ON SAVE.'; + pathname.endsWith('/add') || !webhookKeyMeta.initialValue; const webhookServiceOptions = [ { value: '', @@ -162,9 +160,7 @@ function WebhookSubForm({ templateType }) { webhookCredentialMeta.initialValue ); } else { - webhookKeyHelpers.setValue( - (t`a new webhook key will be generated on save.`).toUpperCase() - ); + webhookKeyHelpers.setValue(''); webhookCredentialHelpers.setValue(null); } }} @@ -205,7 +201,8 @@ function WebhookSubForm({ templateType }) { id="template-webhook_key" aria-label={t`workflow job template webhook key`} value={webhookKeyField.value} - readOnlyVariant="default" + placeholder={t`Leave blank to generate a new webhook key on save`} + onChange={(_event, val) => webhookKeyHelpers.setValue(val)} />