Skip to content

Commit ce23209

Browse files
authored
events: add configurable headers to webhooks (#13602)
* events: add configurable headers to webhooks Signed-off-by: Jens Langhammer <[email protected]> * make it a full thing Signed-off-by: Jens Langhammer <[email protected]> * fix migration Signed-off-by: Jens Langhammer <[email protected]> --------- Signed-off-by: Jens Langhammer <[email protected]>
1 parent 0b806b7 commit ce23209

8 files changed

+163
-22
lines changed

authentik/events/api/notification_transports.py

+2-1
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,8 @@ class Meta:
5050
"mode",
5151
"mode_verbose",
5252
"webhook_url",
53-
"webhook_mapping",
53+
"webhook_mapping_body",
54+
"webhook_mapping_headers",
5455
"send_once",
5556
]
5657

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
# Generated by Django 5.0.13 on 2025-03-20 19:54
2+
3+
import django.db.models.deletion
4+
from django.db import migrations, models
5+
6+
7+
class Migration(migrations.Migration):
8+
9+
dependencies = [
10+
("authentik_events", "0008_event_authentik_e_expires_8c73a8_idx_and_more"),
11+
]
12+
13+
operations = [
14+
migrations.RenameField(
15+
model_name="notificationtransport",
16+
old_name="webhook_mapping",
17+
new_name="webhook_mapping_body",
18+
),
19+
migrations.AlterField(
20+
model_name="notificationtransport",
21+
name="webhook_mapping_body",
22+
field=models.ForeignKey(
23+
default=None,
24+
help_text="Customize the body of the request. Mapping should return data that is JSON-serializable.",
25+
null=True,
26+
on_delete=django.db.models.deletion.SET_DEFAULT,
27+
related_name="+",
28+
to="authentik_events.notificationwebhookmapping",
29+
),
30+
),
31+
migrations.AddField(
32+
model_name="notificationtransport",
33+
name="webhook_mapping_headers",
34+
field=models.ForeignKey(
35+
default=None,
36+
help_text="Configure additional headers to be sent. Mapping should return a dictionary of key-value pairs",
37+
null=True,
38+
on_delete=django.db.models.deletion.SET_DEFAULT,
39+
related_name="+",
40+
to="authentik_events.notificationwebhookmapping",
41+
),
42+
),
43+
]

authentik/events/models.py

+35-6
Original file line numberDiff line numberDiff line change
@@ -336,8 +336,27 @@ class NotificationTransport(SerializerModel):
336336
mode = models.TextField(choices=TransportMode.choices, default=TransportMode.LOCAL)
337337

338338
webhook_url = models.TextField(blank=True, validators=[DomainlessURLValidator()])
339-
webhook_mapping = models.ForeignKey(
340-
"NotificationWebhookMapping", on_delete=models.SET_DEFAULT, null=True, default=None
339+
webhook_mapping_body = models.ForeignKey(
340+
"NotificationWebhookMapping",
341+
on_delete=models.SET_DEFAULT,
342+
null=True,
343+
default=None,
344+
related_name="+",
345+
help_text=_(
346+
"Customize the body of the request. "
347+
"Mapping should return data that is JSON-serializable."
348+
),
349+
)
350+
webhook_mapping_headers = models.ForeignKey(
351+
"NotificationWebhookMapping",
352+
on_delete=models.SET_DEFAULT,
353+
null=True,
354+
default=None,
355+
related_name="+",
356+
help_text=_(
357+
"Configure additional headers to be sent. "
358+
"Mapping should return a dictionary of key-value pairs"
359+
),
341360
)
342361
send_once = models.BooleanField(
343362
default=False,
@@ -360,8 +379,8 @@ def send(self, notification: "Notification") -> list[str]:
360379

361380
def send_local(self, notification: "Notification") -> list[str]:
362381
"""Local notification delivery"""
363-
if self.webhook_mapping:
364-
self.webhook_mapping.evaluate(
382+
if self.webhook_mapping_body:
383+
self.webhook_mapping_body.evaluate(
365384
user=notification.user,
366385
request=None,
367386
notification=notification,
@@ -380,9 +399,18 @@ def send_webhook(self, notification: "Notification") -> list[str]:
380399
if notification.event and notification.event.user:
381400
default_body["event_user_email"] = notification.event.user.get("email", None)
382401
default_body["event_user_username"] = notification.event.user.get("username", None)
383-
if self.webhook_mapping:
402+
headers = {}
403+
if self.webhook_mapping_body:
384404
default_body = sanitize_item(
385-
self.webhook_mapping.evaluate(
405+
self.webhook_mapping_body.evaluate(
406+
user=notification.user,
407+
request=None,
408+
notification=notification,
409+
)
410+
)
411+
if self.webhook_mapping_headers:
412+
headers = sanitize_item(
413+
self.webhook_mapping_headers.evaluate(
386414
user=notification.user,
387415
request=None,
388416
notification=notification,
@@ -392,6 +420,7 @@ def send_webhook(self, notification: "Notification") -> list[str]:
392420
response = get_http_session().post(
393421
self.webhook_url,
394422
json=default_body,
423+
headers=headers,
395424
)
396425
response.raise_for_status()
397426
except RequestException as exc:

authentik/events/tests/test_notifications.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,7 @@ def test_transport_mapping(self):
120120
)
121121

122122
transport = NotificationTransport.objects.create(
123-
name=generate_id(), webhook_mapping=mapping, mode=TransportMode.LOCAL
123+
name=generate_id(), webhook_mapping_body=mapping, mode=TransportMode.LOCAL
124124
)
125125
NotificationRule.objects.filter(name__startswith="default").delete()
126126
trigger = NotificationRule.objects.create(name=generate_id(), group=self.group)

authentik/events/tests/test_transports.py

+7-2
Original file line numberDiff line numberDiff line change
@@ -60,20 +60,25 @@ def test_transport_webhook(self):
6060

6161
def test_transport_webhook_mapping(self):
6262
"""Test webhook transport with custom mapping"""
63-
mapping = NotificationWebhookMapping.objects.create(
63+
mapping_body = NotificationWebhookMapping.objects.create(
6464
name=generate_id(), expression="return request.user"
6565
)
66+
mapping_headers = NotificationWebhookMapping.objects.create(
67+
name=generate_id(), expression="""return {"foo": "bar"}"""
68+
)
6669
transport: NotificationTransport = NotificationTransport.objects.create(
6770
name=generate_id(),
6871
mode=TransportMode.WEBHOOK,
6972
webhook_url="http://localhost:1234/test",
70-
webhook_mapping=mapping,
73+
webhook_mapping_body=mapping_body,
74+
webhook_mapping_headers=mapping_headers,
7175
)
7276
with Mocker() as mocker:
7377
mocker.post("http://localhost:1234/test")
7478
transport.send(self.notification)
7579
self.assertEqual(mocker.call_count, 1)
7680
self.assertEqual(mocker.request_history[0].method, "POST")
81+
self.assertEqual(mocker.request_history[0].headers["foo"], "bar")
7782
self.assertJSONEqual(
7883
mocker.request_history[0].body.decode(),
7984
{"email": self.user.email, "pk": self.user.pk, "username": self.user.username},

blueprints/schema.json

+8-2
Original file line numberDiff line numberDiff line change
@@ -14906,9 +14906,15 @@
1490614906
"type": "string",
1490714907
"title": "Webhook url"
1490814908
},
14909-
"webhook_mapping": {
14909+
"webhook_mapping_body": {
1491014910
"type": "integer",
14911-
"title": "Webhook mapping"
14911+
"title": "Webhook mapping body",
14912+
"description": "Customize the body of the request. Mapping should return data that is JSON-serializable."
14913+
},
14914+
"webhook_mapping_headers": {
14915+
"type": "integer",
14916+
"title": "Webhook mapping headers",
14917+
"description": "Configure additional headers to be sent. Mapping should return a dictionary of key-value pairs"
1491214918
},
1491314919
"send_once": {
1491414920
"type": "boolean",

schema.yml

+27-3
Original file line numberDiff line numberDiff line change
@@ -46890,10 +46890,18 @@ components:
4689046890
webhook_url:
4689146891
type: string
4689246892
format: uri
46893-
webhook_mapping:
46893+
webhook_mapping_body:
4689446894
type: string
4689546895
format: uuid
4689646896
nullable: true
46897+
description: Customize the body of the request. Mapping should return data
46898+
that is JSON-serializable.
46899+
webhook_mapping_headers:
46900+
type: string
46901+
format: uuid
46902+
nullable: true
46903+
description: Configure additional headers to be sent. Mapping should return
46904+
a dictionary of key-value pairs
4689746905
send_once:
4689846906
type: boolean
4689946907
description: Only send notification once, for example when sending a webhook
@@ -46921,10 +46929,18 @@ components:
4692146929
webhook_url:
4692246930
type: string
4692346931
format: uri
46924-
webhook_mapping:
46932+
webhook_mapping_body:
4692546933
type: string
4692646934
format: uuid
4692746935
nullable: true
46936+
description: Customize the body of the request. Mapping should return data
46937+
that is JSON-serializable.
46938+
webhook_mapping_headers:
46939+
type: string
46940+
format: uuid
46941+
nullable: true
46942+
description: Configure additional headers to be sent. Mapping should return
46943+
a dictionary of key-value pairs
4692846944
send_once:
4692946945
type: boolean
4693046946
description: Only send notification once, for example when sending a webhook
@@ -51358,10 +51374,18 @@ components:
5135851374
webhook_url:
5135951375
type: string
5136051376
format: uri
51361-
webhook_mapping:
51377+
webhook_mapping_body:
51378+
type: string
51379+
format: uuid
51380+
nullable: true
51381+
description: Customize the body of the request. Mapping should return data
51382+
that is JSON-serializable.
51383+
webhook_mapping_headers:
5136251384
type: string
5136351385
format: uuid
5136451386
nullable: true
51387+
description: Configure additional headers to be sent. Mapping should return
51388+
a dictionary of key-value pairs
5136551389
send_once:
5136651390
type: boolean
5136751391
description: Only send notification once, for example when sending a webhook

web/src/admin/events/TransportForm.ts

+40-7
Original file line numberDiff line numberDiff line change
@@ -66,15 +66,15 @@ export class TransportForm extends ModelForm<NotificationTransport, string> {
6666
}
6767

6868
renderForm(): TemplateResult {
69-
return html` <ak-form-element-horizontal label=${msg("Name")} ?required=${true} name="name">
69+
return html` <ak-form-element-horizontal label=${msg("Name")} required name="name">
7070
<input
7171
type="text"
7272
value="${ifDefined(this.instance?.name)}"
7373
class="pf-c-form-control"
7474
required
7575
/>
7676
</ak-form-element-horizontal>
77-
<ak-form-element-horizontal label=${msg("Mode")} ?required=${true} name="mode">
77+
<ak-form-element-horizontal label=${msg("Mode")} required name="mode">
7878
<ak-radio
7979
@change=${(ev: CustomEvent<{ value: NotificationTransportModeEnum }>) => {
8080
this.onModeChange(ev.detail.value);
@@ -106,7 +106,7 @@ export class TransportForm extends ModelForm<NotificationTransport, string> {
106106
?hidden=${!this.showWebhook}
107107
label=${msg("Webhook URL")}
108108
name="webhookUrl"
109-
?required=${true}
109+
required
110110
>
111111
<input
112112
type="text"
@@ -116,8 +116,8 @@ export class TransportForm extends ModelForm<NotificationTransport, string> {
116116
</ak-form-element-horizontal>
117117
<ak-form-element-horizontal
118118
?hidden=${!this.showWebhook}
119-
label=${msg("Webhook Mapping")}
120-
name="webhookMapping"
119+
label=${msg("Webhook Body Mapping")}
120+
name="webhookMappingBody"
121121
>
122122
<ak-search-select
123123
.fetchObjects=${async (
@@ -141,9 +141,42 @@ export class TransportForm extends ModelForm<NotificationTransport, string> {
141141
return item?.pk;
142142
}}
143143
.selected=${(item: NotificationWebhookMapping): boolean => {
144-
return this.instance?.webhookMapping === item.pk;
144+
return this.instance?.webhookMappingBody === item.pk;
145145
}}
146-
?blankable=${true}
146+
blankable
147+
>
148+
</ak-search-select>
149+
</ak-form-element-horizontal>
150+
<ak-form-element-horizontal
151+
?hidden=${!this.showWebhook}
152+
label=${msg("Webhook Header Mapping")}
153+
name="webhookMappingHeaders"
154+
>
155+
<ak-search-select
156+
.fetchObjects=${async (
157+
query?: string,
158+
): Promise<NotificationWebhookMapping[]> => {
159+
const args: PropertymappingsNotificationListRequest = {
160+
ordering: "name",
161+
};
162+
if (query !== undefined) {
163+
args.search = query;
164+
}
165+
const items = await new PropertymappingsApi(
166+
DEFAULT_CONFIG,
167+
).propertymappingsNotificationList(args);
168+
return items.results;
169+
}}
170+
.renderElement=${(item: NotificationWebhookMapping): string => {
171+
return item.name;
172+
}}
173+
.value=${(item: NotificationWebhookMapping | undefined): string | undefined => {
174+
return item?.pk;
175+
}}
176+
.selected=${(item: NotificationWebhookMapping): boolean => {
177+
return this.instance?.webhookMappingHeaders === item.pk;
178+
}}
179+
blankable
147180
>
148181
</ak-search-select>
149182
</ak-form-element-horizontal>

0 commit comments

Comments
 (0)