Repository: goauthentik/authentik
Pull request: events: add configurable headers to webhooks
<!--
👋 Hi there! Welcome.

Please check the Contributing guidelines: https://docs.goauthentik.io/docs/developer-docs/#how-can-i-contribute
-->

## Details

closes #4875

TODO:
- API
- WebUI
- Rename `webhook_mapping` to `webhook_mapping_body`?

---

## Checklist

-   [ ] Local tests pass (`ak test authentik/`)
-   [ ] The code has been formatted (`make lint-fix`)

If an API change has been made

-   [ ] The API schema has been updated (`make gen-build`)

If changes to the frontend have been made

-   [ ] The code has been formatted (`make web`)

If applicable

-   [ ] The documentation has been updated
-   [ ] The documentation has been formatted (`make website`)


Problem statement:
GET for notification webhooks and POST request template
As of now, only POST webhooks can be set in the notification transport. These webhooks are not configurable and send a predefined JSON, would be nice to be able to template that to support different notification queues systems (ntfy uhm)

Moreover, it would be great if a simple GET would be allowed. Would simplify things as well for simple GET webhooks

Thanks for the amazing work, I am watching and testing out goauthentik

Hints from issue discussion:
The data within the webhook can be customized using a property mapping; see https://docs.goauthentik.io/docs/events/transports#generic-webhook
Hello,

also a big <3 from me, been using authentik for some time now and its only gettting better.

Can we get the ability to send a Header as well?

Commit message: make it a full thing

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

Patch to review:
diff --git a/authentik/events/api/notification_transports.py b/authentik/events/api/notification_transports.py
index 3838d2fa8c93..990fb89e4cdb 100644
--- a/authentik/events/api/notification_transports.py
+++ b/authentik/events/api/notification_transports.py
@@ -50,7 +50,8 @@ class Meta:
             "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 000000000000..db747ce78dfc
--- /dev/null
+++ b/authentik/events/migrations/0009_remove_notificationtransport_webhook_mapping_and_more.py
@@ -0,0 +1,42 @@
+# 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.RemoveField(
+            model_name="notificationtransport",
+            name="webhook_mapping",
+        ),
+        migrations.AddField(
+            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 e7e0256e81f1..6ade3c107de0 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 @@ def send(self, notification: "Notification") -> list[str]:
 
     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 @@ def send_webhook(self, notification: "Notification") -> list[str]:
         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 @@ def send_webhook(self, notification: "Notification") -> list[str]:
             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 527d7e5cd649..6fd6050cc595 100644
--- a/authentik/events/tests/test_notifications.py
+++ b/authentik/events/tests/test_notifications.py
@@ -120,7 +120,7 @@ def test_transport_mapping(self):
         )
 
         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 23ea92437635..f4be8cc42999 100644
--- a/authentik/events/tests/test_transports.py
+++ b/authentik/events/tests/test_transports.py
@@ -60,20 +60,25 @@ def test_transport_webhook(self):
 
     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 140fcb31b1ec..9188d58568aa 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 1b03e0ac42d2..396878442501 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 e33a384694f3..cce51bd8cabb 100644
--- a/web/src/admin/events/TransportForm.ts
+++ b/web/src/admin/events/TransportForm.ts
@@ -66,7 +66,7 @@ export class TransportForm extends ModelForm<NotificationTransport, string> {
     }
 
     renderForm(): TemplateResult {
-        return html` <ak-form-element-horizontal label=${msg("Name")} ?required=${true} name="name">
+        return html` <ak-form-element-horizontal label=${msg("Name")} required name="name">
                 <input
                     type="text"
                     value="${ifDefined(this.instance?.name)}"
@@ -74,7 +74,7 @@ export class TransportForm extends ModelForm<NotificationTransport, string> {
                     required
                 />
             </ak-form-element-horizontal>
-            <ak-form-element-horizontal label=${msg("Mode")} ?required=${true} name="mode">
+            <ak-form-element-horizontal label=${msg("Mode")} required name="mode">
                 <ak-radio
                     @change=${(ev: CustomEvent<{ value: NotificationTransportModeEnum }>) => {
                         this.onModeChange(ev.detail.value);
@@ -106,7 +106,7 @@ export class TransportForm extends ModelForm<NotificationTransport, string> {
                 ?hidden=${!this.showWebhook}
                 label=${msg("Webhook URL")}
                 name="webhookUrl"
-                ?required=${true}
+                required
             >
                 <input
                     type="text"
@@ -116,8 +116,8 @@ export class TransportForm extends ModelForm<NotificationTransport, string> {
             </ak-form-element-horizontal>
             <ak-form-element-horizontal
                 ?hidden=${!this.showWebhook}
-                label=${msg("Webhook Mapping")}
-                name="webhookMapping"
+                label=${msg("Webhook Body Mapping")}
+                name="webhookMappingBody"
             >
                 <ak-search-select
                     .fetchObjects=${async (
@@ -141,9 +141,42 @@ export class TransportForm extends ModelForm<NotificationTransport, string> {
                         return item?.pk;
                     }}
                     .selected=${(item: NotificationWebhookMapping): boolean => {
-                        return this.instance?.webhookMapping === item.pk;
+                        return this.instance?.webhookMappingBody === item.pk;
                     }}
-                    ?blankable=${true}
+                    blankable
+                >
+                </ak-search-select>
+            </ak-form-element-horizontal>
+            <ak-form-element-horizontal
+                ?hidden=${!this.showWebhook}
+                label=${msg("Webhook Header Mapping")}
+                name="webhookMappingHeaders"
+            >
+                <ak-search-select
+                    .fetchObjects=${async (
+                        query?: string,
+                    ): Promise<NotificationWebhookMapping[]> => {
+                        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
                 >
                 </ak-search-select>
             </ak-form-element-horizontal>


Code review comments:
diff_hunk: @@ -0,0 +1,42 @@
+# 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.RemoveField(
+            model_name="notificationtransport",
+            name="webhook_mapping",
+        ),
path: authentik/events/migrations/0009_remove_notificationtransport_webhook_mapping_and_more.py
text: @user1:
Is this intended? It should be a rename I think

@author:
Good catch, didn't notice that it didn't rename the field 

@user1:
When running makemigrations it should ask whether the field was renamed. If it doesn't, something is fishy

@author:
It didn't, probably because I added related_name and that was enough to throw it off