From ce23209ae84548fe7403e9a7e558e57254f255f2 Mon Sep 17 00:00:00 2001 From: "Jens L." Date: Fri, 21 Mar 2025 19:37:15 +0000 Subject: [PATCH] events: add configurable headers to webhooks (#13602) * events: add configurable headers to webhooks Signed-off-by: Jens Langhammer * make it a full thing Signed-off-by: Jens Langhammer * fix migration Signed-off-by: Jens Langhammer --------- Signed-off-by: Jens Langhammer --- .../events/api/notification_transports.py | 3 +- ...ationtransport_webhook_mapping_and_more.py | 43 +++++++++++++++++ authentik/events/models.py | 41 +++++++++++++--- authentik/events/tests/test_notifications.py | 2 +- authentik/events/tests/test_transports.py | 9 +++- blueprints/schema.json | 10 +++- schema.yml | 30 ++++++++++-- web/src/admin/events/TransportForm.ts | 47 ++++++++++++++++--- 8 files changed, 163 insertions(+), 22 deletions(-) create mode 100644 authentik/events/migrations/0009_remove_notificationtransport_webhook_mapping_and_more.py diff --git a/authentik/events/api/notification_transports.py b/authentik/events/api/notification_transports.py index 3838d2fa8c..990fb89e4c 100644 --- a/authentik/events/api/notification_transports.py +++ b/authentik/events/api/notification_transports.py @@ -50,7 +50,8 @@ class NotificationTransportSerializer(ModelSerializer): "mode", "mode_verbose", "webhook_url", - "webhook_mapping", + "webhook_mapping_body", + "webhook_mapping_headers", "send_once", ] diff --git a/authentik/events/migrations/0009_remove_notificationtransport_webhook_mapping_and_more.py b/authentik/events/migrations/0009_remove_notificationtransport_webhook_mapping_and_more.py new file mode 100644 index 0000000000..abeaef3a9d --- /dev/null +++ b/authentik/events/migrations/0009_remove_notificationtransport_webhook_mapping_and_more.py @@ -0,0 +1,43 @@ +# Generated by Django 5.0.13 on 2025-03-20 19:54 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_events", "0008_event_authentik_e_expires_8c73a8_idx_and_more"), + ] + + operations = [ + migrations.RenameField( + model_name="notificationtransport", + old_name="webhook_mapping", + new_name="webhook_mapping_body", + ), + migrations.AlterField( + model_name="notificationtransport", + name="webhook_mapping_body", + field=models.ForeignKey( + default=None, + help_text="Customize the body of the request. Mapping should return data that is JSON-serializable.", + null=True, + on_delete=django.db.models.deletion.SET_DEFAULT, + related_name="+", + to="authentik_events.notificationwebhookmapping", + ), + ), + migrations.AddField( + model_name="notificationtransport", + name="webhook_mapping_headers", + field=models.ForeignKey( + default=None, + help_text="Configure additional headers to be sent. Mapping should return a dictionary of key-value pairs", + null=True, + on_delete=django.db.models.deletion.SET_DEFAULT, + related_name="+", + to="authentik_events.notificationwebhookmapping", + ), + ), + ] diff --git a/authentik/events/models.py b/authentik/events/models.py index e7e0256e81..6ade3c107d 100644 --- a/authentik/events/models.py +++ b/authentik/events/models.py @@ -336,8 +336,27 @@ class NotificationTransport(SerializerModel): mode = models.TextField(choices=TransportMode.choices, default=TransportMode.LOCAL) webhook_url = models.TextField(blank=True, validators=[DomainlessURLValidator()]) - webhook_mapping = models.ForeignKey( - "NotificationWebhookMapping", on_delete=models.SET_DEFAULT, null=True, default=None + webhook_mapping_body = models.ForeignKey( + "NotificationWebhookMapping", + on_delete=models.SET_DEFAULT, + null=True, + default=None, + related_name="+", + help_text=_( + "Customize the body of the request. " + "Mapping should return data that is JSON-serializable." + ), + ) + webhook_mapping_headers = models.ForeignKey( + "NotificationWebhookMapping", + on_delete=models.SET_DEFAULT, + null=True, + default=None, + related_name="+", + help_text=_( + "Configure additional headers to be sent. " + "Mapping should return a dictionary of key-value pairs" + ), ) send_once = models.BooleanField( default=False, @@ -360,8 +379,8 @@ class NotificationTransport(SerializerModel): def send_local(self, notification: "Notification") -> list[str]: """Local notification delivery""" - if self.webhook_mapping: - self.webhook_mapping.evaluate( + if self.webhook_mapping_body: + self.webhook_mapping_body.evaluate( user=notification.user, request=None, notification=notification, @@ -380,9 +399,18 @@ class NotificationTransport(SerializerModel): if notification.event and notification.event.user: default_body["event_user_email"] = notification.event.user.get("email", None) default_body["event_user_username"] = notification.event.user.get("username", None) - if self.webhook_mapping: + headers = {} + if self.webhook_mapping_body: default_body = sanitize_item( - self.webhook_mapping.evaluate( + self.webhook_mapping_body.evaluate( + user=notification.user, + request=None, + notification=notification, + ) + ) + if self.webhook_mapping_headers: + headers = sanitize_item( + self.webhook_mapping_headers.evaluate( user=notification.user, request=None, notification=notification, @@ -392,6 +420,7 @@ class NotificationTransport(SerializerModel): response = get_http_session().post( self.webhook_url, json=default_body, + headers=headers, ) response.raise_for_status() except RequestException as exc: diff --git a/authentik/events/tests/test_notifications.py b/authentik/events/tests/test_notifications.py index 527d7e5cd6..6fd6050cc5 100644 --- a/authentik/events/tests/test_notifications.py +++ b/authentik/events/tests/test_notifications.py @@ -120,7 +120,7 @@ class TestEventsNotifications(APITestCase): ) transport = NotificationTransport.objects.create( - name=generate_id(), webhook_mapping=mapping, mode=TransportMode.LOCAL + name=generate_id(), webhook_mapping_body=mapping, mode=TransportMode.LOCAL ) NotificationRule.objects.filter(name__startswith="default").delete() trigger = NotificationRule.objects.create(name=generate_id(), group=self.group) diff --git a/authentik/events/tests/test_transports.py b/authentik/events/tests/test_transports.py index 23ea924376..f4be8cc429 100644 --- a/authentik/events/tests/test_transports.py +++ b/authentik/events/tests/test_transports.py @@ -60,20 +60,25 @@ class TestEventTransports(TestCase): def test_transport_webhook_mapping(self): """Test webhook transport with custom mapping""" - mapping = NotificationWebhookMapping.objects.create( + mapping_body = NotificationWebhookMapping.objects.create( name=generate_id(), expression="return request.user" ) + mapping_headers = NotificationWebhookMapping.objects.create( + name=generate_id(), expression="""return {"foo": "bar"}""" + ) transport: NotificationTransport = NotificationTransport.objects.create( name=generate_id(), mode=TransportMode.WEBHOOK, webhook_url="http://localhost:1234/test", - webhook_mapping=mapping, + webhook_mapping_body=mapping_body, + webhook_mapping_headers=mapping_headers, ) with Mocker() as mocker: mocker.post("http://localhost:1234/test") transport.send(self.notification) self.assertEqual(mocker.call_count, 1) self.assertEqual(mocker.request_history[0].method, "POST") + self.assertEqual(mocker.request_history[0].headers["foo"], "bar") self.assertJSONEqual( mocker.request_history[0].body.decode(), {"email": self.user.email, "pk": self.user.pk, "username": self.user.username}, diff --git a/blueprints/schema.json b/blueprints/schema.json index 140fcb31b1..9188d58568 100644 --- a/blueprints/schema.json +++ b/blueprints/schema.json @@ -14906,9 +14906,15 @@ "type": "string", "title": "Webhook url" }, - "webhook_mapping": { + "webhook_mapping_body": { "type": "integer", - "title": "Webhook mapping" + "title": "Webhook mapping body", + "description": "Customize the body of the request. Mapping should return data that is JSON-serializable." + }, + "webhook_mapping_headers": { + "type": "integer", + "title": "Webhook mapping headers", + "description": "Configure additional headers to be sent. Mapping should return a dictionary of key-value pairs" }, "send_once": { "type": "boolean", diff --git a/schema.yml b/schema.yml index 1b03e0ac42..3968784425 100644 --- a/schema.yml +++ b/schema.yml @@ -46890,10 +46890,18 @@ components: webhook_url: type: string format: uri - webhook_mapping: + webhook_mapping_body: type: string format: uuid nullable: true + description: Customize the body of the request. Mapping should return data + that is JSON-serializable. + webhook_mapping_headers: + type: string + format: uuid + nullable: true + description: Configure additional headers to be sent. Mapping should return + a dictionary of key-value pairs send_once: type: boolean description: Only send notification once, for example when sending a webhook @@ -46921,10 +46929,18 @@ components: webhook_url: type: string format: uri - webhook_mapping: + webhook_mapping_body: type: string format: uuid nullable: true + description: Customize the body of the request. Mapping should return data + that is JSON-serializable. + webhook_mapping_headers: + type: string + format: uuid + nullable: true + description: Configure additional headers to be sent. Mapping should return + a dictionary of key-value pairs send_once: type: boolean description: Only send notification once, for example when sending a webhook @@ -51358,10 +51374,18 @@ components: webhook_url: type: string format: uri - webhook_mapping: + webhook_mapping_body: type: string format: uuid nullable: true + description: Customize the body of the request. Mapping should return data + that is JSON-serializable. + webhook_mapping_headers: + type: string + format: uuid + nullable: true + description: Configure additional headers to be sent. Mapping should return + a dictionary of key-value pairs send_once: type: boolean description: Only send notification once, for example when sending a webhook diff --git a/web/src/admin/events/TransportForm.ts b/web/src/admin/events/TransportForm.ts index e33a384694..cce51bd8ca 100644 --- a/web/src/admin/events/TransportForm.ts +++ b/web/src/admin/events/TransportForm.ts @@ -66,7 +66,7 @@ export class TransportForm extends ModelForm { } renderForm(): TemplateResult { - return html` + return html` { required /> - + ) => { this.onModeChange(ev.detail.value); @@ -106,7 +106,7 @@ export class TransportForm extends ModelForm { ?hidden=${!this.showWebhook} label=${msg("Webhook URL")} name="webhookUrl" - ?required=${true} + required > { { return item?.pk; }} .selected=${(item: NotificationWebhookMapping): boolean => { - return this.instance?.webhookMapping === item.pk; + return this.instance?.webhookMappingBody === item.pk; }} - ?blankable=${true} + blankable + > + + + + => { + const args: PropertymappingsNotificationListRequest = { + ordering: "name", + }; + if (query !== undefined) { + args.search = query; + } + const items = await new PropertymappingsApi( + DEFAULT_CONFIG, + ).propertymappingsNotificationList(args); + return items.results; + }} + .renderElement=${(item: NotificationWebhookMapping): string => { + return item.name; + }} + .value=${(item: NotificationWebhookMapping | undefined): string | undefined => { + return item?.pk; + }} + .selected=${(item: NotificationWebhookMapping): boolean => { + return this.instance?.webhookMappingHeaders === item.pk; + }} + blankable >