Skip to content

Commit d57b1e8

Browse files
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.
1 parent 9903a04 commit d57b1e8

21 files changed

Lines changed: 194 additions & 27 deletions

File tree

awx/api/serializers.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1499,6 +1499,13 @@ class ProjectSerializer(UnifiedJobTemplateSerializer, ProjectOptionsSerializer):
14991499
status = serializers.ChoiceField(choices=Project.PROJECT_STATUS_CHOICES, read_only=True)
15001500
last_update_failed = serializers.BooleanField(read_only=True)
15011501
last_updated = serializers.DateTimeField(read_only=True)
1502+
webhook_key = serializers.CharField(
1503+
write_only=True,
1504+
required=False,
1505+
allow_blank=True,
1506+
max_length=64,
1507+
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.'),
1508+
)
15021509
show_capabilities = ['start', 'schedule', 'edit', 'delete', 'copy']
15031510
capabilities_prefetch = ['admin', 'update', {'copy': 'organization.project_admin'}]
15041511

@@ -1514,6 +1521,7 @@ class Meta:
15141521
'default_environment',
15151522
'signature_validation_credential',
15161523
'webhook_service',
1524+
'webhook_key',
15171525
'webhook_ref_filter',
15181526
) + (
15191527
'last_update_failed',
@@ -1591,6 +1599,8 @@ def get_field_from_model_or_attrs(fd):
15911599
raise serializers.ValidationError({fd: _('Update options must be set to false for manual projects.')})
15921600
if get_field_from_model_or_attrs('webhook_service') and not get_field_from_model_or_attrs('scm_type'):
15931601
raise serializers.ValidationError({'webhook_service': _('Webhooks are not supported for manual projects.')})
1602+
if attrs.get('webhook_key') and not get_field_from_model_or_attrs('webhook_service'):
1603+
raise serializers.ValidationError({'webhook_key': _("Cannot set a webhook key without a webhook service.")})
15941604
return super(ProjectSerializer, self).validate(attrs)
15951605

15961606

@@ -3295,6 +3305,9 @@ def validate(self, attrs):
32953305
webhook_service = attrs.get('webhook_service', getattr(self.instance, 'webhook_service', None))
32963306
webhook_credential = attrs.get('webhook_credential', getattr(self.instance, 'webhook_credential', None))
32973307

3308+
if attrs.get('webhook_key') and not webhook_service:
3309+
raise serializers.ValidationError({'webhook_key': _("Cannot set a webhook key without a webhook service.")})
3310+
32983311
if webhook_credential:
32993312
if webhook_credential.credential_type.kind != 'token':
33003313
raise serializers.ValidationError({'webhook_credential': _("Must be a Personal Access Token.")})
@@ -3314,6 +3327,13 @@ class JobTemplateSerializer(JobTemplateMixin, UnifiedJobTemplateSerializer, JobO
33143327
capabilities_prefetch = ['admin', 'execute', {'copy': ['project.use', 'inventory.use']}]
33153328

33163329
status = serializers.ChoiceField(choices=JobTemplate.JOB_TEMPLATE_STATUS_CHOICES, read_only=True, required=False)
3330+
webhook_key = serializers.CharField(
3331+
write_only=True,
3332+
required=False,
3333+
allow_blank=True,
3334+
max_length=64,
3335+
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.'),
3336+
)
33173337

33183338
class Meta:
33193339
model = JobTemplate
@@ -3343,6 +3363,7 @@ class Meta:
33433363
'job_slice_count',
33443364
'webhook_service',
33453365
'webhook_credential',
3366+
'webhook_key',
33463367
'prevent_instance_group_fallback',
33473368
)
33483369
read_only_fields = ('*',)
@@ -3797,6 +3818,13 @@ class WorkflowJobTemplateSerializer(JobTemplateMixin, LabelsListMixin, UnifiedJo
37973818

37983819
skip_tags = serializers.CharField(allow_blank=True, allow_null=True, required=False, default=None)
37993820
job_tags = serializers.CharField(allow_blank=True, allow_null=True, required=False, default=None)
3821+
webhook_key = serializers.CharField(
3822+
write_only=True,
3823+
required=False,
3824+
allow_blank=True,
3825+
max_length=64,
3826+
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.'),
3827+
)
38003828

38013829
class Meta:
38023830
model = WorkflowJobTemplate
@@ -3815,6 +3843,7 @@ class Meta:
38153843
'ask_limit_on_launch',
38163844
'webhook_service',
38173845
'webhook_credential',
3846+
'webhook_key',
38183847
'-execution_environment',
38193848
'ask_labels_on_launch',
38203849
'ask_skip_tags_on_launch',

awx/api/templates/api/webhook_key_view.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,6 @@ by webhook events. The response will include the following fields:
1010

1111
Make an empty POST request to this resource to generate a new
1212
replacement `webhook_key`.
13+
14+
A specific key can also be set by writing to the `webhook_key` field
15+
of the job template, workflow job template or project resource itself.

awx/main/models/jobs.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -235,7 +235,7 @@ class JobTemplate(UnifiedJobTemplate, JobOptions, SurveyJobTemplateMixin, Resour
235235
"""
236236

237237
FIELDS_TO_PRESERVE_AT_COPY = ['labels', 'instance_groups', 'credentials', 'survey_spec', 'prevent_instance_group_fallback']
238-
FIELDS_TO_DISCARD_AT_COPY = ['vault_credential', 'credential']
238+
FIELDS_TO_DISCARD_AT_COPY = ['vault_credential', 'credential', 'webhook_key']
239239
SOFT_UNIQUE_TOGETHER = [('polymorphic_ctype', 'name', 'organization')]
240240

241241
class Meta:

awx/main/models/mixins.py

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -570,14 +570,26 @@ def rotate_webhook_key(self):
570570
def save(self, *args, **kwargs):
571571
update_fields = kwargs.get('update_fields')
572572

573-
if not self.pk or self._values_have_edits({'webhook_service': self.webhook_service}):
574-
if self.webhook_service:
575-
self.rotate_webhook_key()
576-
else:
573+
if self.pk:
574+
service_edited = self._values_have_edits({'webhook_service': self.webhook_service})
575+
key_edited = self._values_have_edits({'webhook_key': self.webhook_key})
576+
else:
577+
service_edited = True
578+
key_edited = bool(self.webhook_key)
579+
580+
if service_edited:
581+
if not self.webhook_service:
577582
self.webhook_key = ''
583+
elif not (key_edited and self.webhook_key):
584+
# No key was supplied by the caller, generate one. A caller
585+
# provided key (e.g. one managed as configuration) is kept as is.
586+
self.rotate_webhook_key()
578587

579-
if update_fields and 'webhook_service' in update_fields:
588+
if update_fields and 'webhook_service' in update_fields and 'webhook_key' not in update_fields:
580589
update_fields.add('webhook_key')
590+
elif key_edited and self.webhook_service and not self.webhook_key:
591+
# Blanking the key of an active webhook means please generate a new one.
592+
self.rotate_webhook_key()
581593

582594
super().save(*args, **kwargs)
583595

awx/main/models/projects.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -255,7 +255,7 @@ class Project(UnifiedJobTemplate, ProjectOptions, ResourceMixin, RelatedJobsMixi
255255

256256
SOFT_UNIQUE_TOGETHER = [('polymorphic_ctype', 'name', 'organization')]
257257
FIELDS_TO_PRESERVE_AT_COPY = ['labels', 'instance_groups', 'credentials']
258-
FIELDS_TO_DISCARD_AT_COPY = ['local_path']
258+
FIELDS_TO_DISCARD_AT_COPY = ['local_path', 'webhook_key']
259259
FIELDS_TRIGGER_UPDATE = frozenset(['scm_url', 'scm_branch', 'scm_type', 'scm_refspec'])
260260

261261
class Meta:

awx/main/models/workflow.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -544,6 +544,7 @@ class WorkflowJobTemplate(UnifiedJobTemplate, WorkflowJobOptions, SurveyJobTempl
544544
'job_tags',
545545
'execution_environment',
546546
]
547+
FIELDS_TO_DISCARD_AT_COPY = ['webhook_key']
547548

548549
class Meta:
549550
app_label = 'main'

awx/main/tests/functional/api/test_webhooks.py

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -350,6 +350,98 @@ def test_set_webhook_service_manual_project(manual_project, patch, admin):
350350
assert response.data == {'webhook_service': ["Webhooks are not supported for manual projects."]}
351351

352352

353+
@pytest.mark.django_db
354+
@pytest.mark.parametrize(
355+
"model_kwarg, url_name",
356+
[
357+
('projects', 'api:project_detail'),
358+
('job_templates', 'api:job_template_detail'),
359+
('workflow_job_templates', 'api:workflow_job_template_detail'),
360+
],
361+
)
362+
def test_set_custom_webhook_key(organization_factory, job_template_factory, workflow_job_template_factory, project, patch, get, admin, model_kwarg, url_name):
363+
objs = organization_factory("org")
364+
if model_kwarg == 'projects':
365+
obj = project
366+
elif model_kwarg == 'job_templates':
367+
obj = job_template_factory("jt", organization=objs.organization, inventory='test_inv', project='test_proj').job_template
368+
else:
369+
obj = workflow_job_template_factory("wfjt", organization=objs.organization).workflow_job_template
370+
371+
url = reverse(url_name, kwargs={'pk': obj.pk})
372+
response = patch(url, {'webhook_service': 'github', 'webhook_key': 'secret-managed-as-config'}, user=admin, expect=200)
373+
obj.refresh_from_db()
374+
375+
assert obj.webhook_service == 'github'
376+
assert obj.webhook_key == 'secret-managed-as-config'
377+
# the key is write only, it can only be read back through the webhook_key endpoint
378+
assert 'webhook_key' not in response.data
379+
380+
key_url = reverse('api:webhook_key', kwargs={'model_kwarg': model_kwarg, 'pk': obj.pk})
381+
response = get(key_url, user=admin, expect=200)
382+
assert response.data == {'webhook_key': 'secret-managed-as-config'}
383+
384+
385+
@pytest.mark.django_db
386+
def test_change_webhook_key_keeps_service(github_project, patch, admin):
387+
old_key = github_project.webhook_key
388+
389+
url = reverse('api:project_detail', kwargs={'pk': github_project.pk})
390+
patch(url, {'webhook_key': 'new-secret'}, user=admin, expect=200)
391+
github_project.refresh_from_db()
392+
393+
assert github_project.webhook_service == 'github'
394+
assert github_project.webhook_key == 'new-secret'
395+
assert github_project.webhook_key != old_key
396+
397+
398+
@pytest.mark.django_db
399+
def test_blank_webhook_key_generates_new_one(github_project, patch, admin):
400+
old_key = github_project.webhook_key
401+
402+
url = reverse('api:project_detail', kwargs={'pk': github_project.pk})
403+
patch(url, {'webhook_key': ''}, user=admin, expect=200)
404+
github_project.refresh_from_db()
405+
406+
assert github_project.webhook_key != ''
407+
assert github_project.webhook_key != old_key
408+
409+
410+
@pytest.mark.django_db
411+
def test_webhook_service_change_rotates_key_unless_key_given(github_project, patch, admin):
412+
old_key = github_project.webhook_key
413+
414+
url = reverse('api:project_detail', kwargs={'pk': github_project.pk})
415+
patch(url, {'webhook_service': 'gitlab'}, user=admin, expect=200)
416+
github_project.refresh_from_db()
417+
assert github_project.webhook_key not in ('', old_key)
418+
419+
patch(url, {'webhook_service': 'github', 'webhook_key': 'pinned-secret'}, user=admin, expect=200)
420+
github_project.refresh_from_db()
421+
assert (github_project.webhook_service, github_project.webhook_key) == ('github', 'pinned-secret')
422+
423+
424+
@pytest.mark.django_db
425+
def test_webhook_key_requires_service(project, patch, admin):
426+
url = reverse('api:project_detail', kwargs={'pk': project.pk})
427+
response = patch(url, {'webhook_key': 'orphan-secret'}, user=admin, expect=400)
428+
429+
assert response.data == {'webhook_key': ["Cannot set a webhook key without a webhook service."]}
430+
431+
432+
@pytest.mark.django_db
433+
def test_copied_project_gets_its_own_webhook_key(github_project, post, admin):
434+
url = reverse('api:project_copy', kwargs={'pk': github_project.pk})
435+
response = post(url, {'name': 'copied-project'}, user=admin, expect=201)
436+
437+
from awx.main.models.projects import Project
438+
439+
copied = Project.objects.get(pk=response.data['id'])
440+
assert copied.webhook_service == 'github'
441+
assert copied.webhook_key != ''
442+
assert copied.webhook_key != github_project.webhook_key
443+
444+
353445
@pytest.mark.django_db
354446
def test_github_push_triggers_project_update(github_project, post):
355447
with mock.patch.object(ProjectUpdate, 'signal_start') as signal_start:

awx/ui/src/screens/Project/ProjectAdd/ProjectAdd.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,9 @@ function ProjectAdd() {
3333
values.signature_validation_credential =
3434
values.signature_validation_credential.id;
3535
}
36+
if (webhook_key) {
37+
values.webhook_key = webhook_key;
38+
}
3639
setFormSubmitError(null);
3740
try {
3841
const {

awx/ui/src/screens/Project/ProjectEdit/ProjectEdit.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,9 @@ function ProjectEdit({ project }) {
3434
values.signature_validation_credential.id;
3535
}
3636

37+
if (webhook_key) {
38+
values.webhook_key = webhook_key;
39+
}
3740
try {
3841
const {
3942
data: { id },

awx/ui/src/screens/Project/shared/Project.helptext.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,9 @@ job will not run.`,
144144
trigger a project sync.`,
145145
webhookKey: t`Secret shared with the webhook service. The service uses
146146
it to sign its requests, so only your repository can
147-
trigger a project sync.`,
147+
trigger a project sync. Type your own secret to manage it
148+
as configuration, or leave the field blank to have one
149+
generated on save.`,
148150
});
149151

150152
export default getProjectHelpText;

0 commit comments

Comments
 (0)