events: add configurable headers to webhooks (#13602)

* events: add configurable headers to webhooks

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

* make it a full thing

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

* fix migration

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

---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
This commit is contained in:
Jens L.
2025-03-21 19:37:15 +00:00
committed by GitHub
parent 0b806b7130
commit ce23209ae8
8 changed files with 163 additions and 22 deletions

View File

@ -50,7 +50,8 @@ class NotificationTransportSerializer(ModelSerializer):
"mode", "mode",
"mode_verbose", "mode_verbose",
"webhook_url", "webhook_url",
"webhook_mapping", "webhook_mapping_body",
"webhook_mapping_headers",
"send_once", "send_once",
] ]

View File

@ -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",
),
),
]

View File

@ -336,8 +336,27 @@ class NotificationTransport(SerializerModel):
mode = models.TextField(choices=TransportMode.choices, default=TransportMode.LOCAL) mode = models.TextField(choices=TransportMode.choices, default=TransportMode.LOCAL)
webhook_url = models.TextField(blank=True, validators=[DomainlessURLValidator()]) webhook_url = models.TextField(blank=True, validators=[DomainlessURLValidator()])
webhook_mapping = models.ForeignKey( webhook_mapping_body = models.ForeignKey(
"NotificationWebhookMapping", on_delete=models.SET_DEFAULT, null=True, default=None "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( send_once = models.BooleanField(
default=False, default=False,
@ -360,8 +379,8 @@ class NotificationTransport(SerializerModel):
def send_local(self, notification: "Notification") -> list[str]: def send_local(self, notification: "Notification") -> list[str]:
"""Local notification delivery""" """Local notification delivery"""
if self.webhook_mapping: if self.webhook_mapping_body:
self.webhook_mapping.evaluate( self.webhook_mapping_body.evaluate(
user=notification.user, user=notification.user,
request=None, request=None,
notification=notification, notification=notification,
@ -380,9 +399,18 @@ class NotificationTransport(SerializerModel):
if notification.event and notification.event.user: if notification.event and notification.event.user:
default_body["event_user_email"] = notification.event.user.get("email", None) default_body["event_user_email"] = notification.event.user.get("email", None)
default_body["event_user_username"] = notification.event.user.get("username", 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( 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, user=notification.user,
request=None, request=None,
notification=notification, notification=notification,
@ -392,6 +420,7 @@ class NotificationTransport(SerializerModel):
response = get_http_session().post( response = get_http_session().post(
self.webhook_url, self.webhook_url,
json=default_body, json=default_body,
headers=headers,
) )
response.raise_for_status() response.raise_for_status()
except RequestException as exc: except RequestException as exc:

View File

@ -120,7 +120,7 @@ class TestEventsNotifications(APITestCase):
) )
transport = NotificationTransport.objects.create( 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() NotificationRule.objects.filter(name__startswith="default").delete()
trigger = NotificationRule.objects.create(name=generate_id(), group=self.group) trigger = NotificationRule.objects.create(name=generate_id(), group=self.group)

View File

@ -60,20 +60,25 @@ class TestEventTransports(TestCase):
def test_transport_webhook_mapping(self): def test_transport_webhook_mapping(self):
"""Test webhook transport with custom mapping""" """Test webhook transport with custom mapping"""
mapping = NotificationWebhookMapping.objects.create( mapping_body = NotificationWebhookMapping.objects.create(
name=generate_id(), expression="return request.user" name=generate_id(), expression="return request.user"
) )
mapping_headers = NotificationWebhookMapping.objects.create(
name=generate_id(), expression="""return {"foo": "bar"}"""
)
transport: NotificationTransport = NotificationTransport.objects.create( transport: NotificationTransport = NotificationTransport.objects.create(
name=generate_id(), name=generate_id(),
mode=TransportMode.WEBHOOK, mode=TransportMode.WEBHOOK,
webhook_url="http://localhost:1234/test", webhook_url="http://localhost:1234/test",
webhook_mapping=mapping, webhook_mapping_body=mapping_body,
webhook_mapping_headers=mapping_headers,
) )
with Mocker() as mocker: with Mocker() as mocker:
mocker.post("http://localhost:1234/test") mocker.post("http://localhost:1234/test")
transport.send(self.notification) transport.send(self.notification)
self.assertEqual(mocker.call_count, 1) self.assertEqual(mocker.call_count, 1)
self.assertEqual(mocker.request_history[0].method, "POST") self.assertEqual(mocker.request_history[0].method, "POST")
self.assertEqual(mocker.request_history[0].headers["foo"], "bar")
self.assertJSONEqual( self.assertJSONEqual(
mocker.request_history[0].body.decode(), mocker.request_history[0].body.decode(),
{"email": self.user.email, "pk": self.user.pk, "username": self.user.username}, {"email": self.user.email, "pk": self.user.pk, "username": self.user.username},

View File

@ -14906,9 +14906,15 @@
"type": "string", "type": "string",
"title": "Webhook url" "title": "Webhook url"
}, },
"webhook_mapping": { "webhook_mapping_body": {
"type": "integer", "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": { "send_once": {
"type": "boolean", "type": "boolean",

View File

@ -46890,10 +46890,18 @@ components:
webhook_url: webhook_url:
type: string type: string
format: uri format: uri
webhook_mapping: webhook_mapping_body:
type: string type: string
format: uuid format: uuid
nullable: true 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: send_once:
type: boolean type: boolean
description: Only send notification once, for example when sending a webhook description: Only send notification once, for example when sending a webhook
@ -46921,10 +46929,18 @@ components:
webhook_url: webhook_url:
type: string type: string
format: uri format: uri
webhook_mapping: webhook_mapping_body:
type: string type: string
format: uuid format: uuid
nullable: true 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: send_once:
type: boolean type: boolean
description: Only send notification once, for example when sending a webhook description: Only send notification once, for example when sending a webhook
@ -51358,10 +51374,18 @@ components:
webhook_url: webhook_url:
type: string type: string
format: uri format: uri
webhook_mapping: webhook_mapping_body:
type: string type: string
format: uuid format: uuid
nullable: true 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: send_once:
type: boolean type: boolean
description: Only send notification once, for example when sending a webhook description: Only send notification once, for example when sending a webhook

View File

@ -66,7 +66,7 @@ export class TransportForm extends ModelForm<NotificationTransport, string> {
} }
renderForm(): TemplateResult { 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 <input
type="text" type="text"
value="${ifDefined(this.instance?.name)}" value="${ifDefined(this.instance?.name)}"
@ -74,7 +74,7 @@ export class TransportForm extends ModelForm<NotificationTransport, string> {
required required
/> />
</ak-form-element-horizontal> </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 <ak-radio
@change=${(ev: CustomEvent<{ value: NotificationTransportModeEnum }>) => { @change=${(ev: CustomEvent<{ value: NotificationTransportModeEnum }>) => {
this.onModeChange(ev.detail.value); this.onModeChange(ev.detail.value);
@ -106,7 +106,7 @@ export class TransportForm extends ModelForm<NotificationTransport, string> {
?hidden=${!this.showWebhook} ?hidden=${!this.showWebhook}
label=${msg("Webhook URL")} label=${msg("Webhook URL")}
name="webhookUrl" name="webhookUrl"
?required=${true} required
> >
<input <input
type="text" type="text"
@ -116,8 +116,8 @@ export class TransportForm extends ModelForm<NotificationTransport, string> {
</ak-form-element-horizontal> </ak-form-element-horizontal>
<ak-form-element-horizontal <ak-form-element-horizontal
?hidden=${!this.showWebhook} ?hidden=${!this.showWebhook}
label=${msg("Webhook Mapping")} label=${msg("Webhook Body Mapping")}
name="webhookMapping" name="webhookMappingBody"
> >
<ak-search-select <ak-search-select
.fetchObjects=${async ( .fetchObjects=${async (
@ -141,9 +141,42 @@ export class TransportForm extends ModelForm<NotificationTransport, string> {
return item?.pk; return item?.pk;
}} }}
.selected=${(item: NotificationWebhookMapping): boolean => { .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-search-select>
</ak-form-element-horizontal> </ak-form-element-horizontal>