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:
@ -50,7 +50,8 @@ class NotificationTransportSerializer(ModelSerializer):
|
||||
"mode",
|
||||
"mode_verbose",
|
||||
"webhook_url",
|
||||
"webhook_mapping",
|
||||
"webhook_mapping_body",
|
||||
"webhook_mapping_headers",
|
||||
"send_once",
|
||||
]
|
||||
|
||||
|
||||
@ -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",
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -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:
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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},
|
||||
|
||||
@ -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",
|
||||
|
||||
30
schema.yml
30
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
|
||||
|
||||
@ -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>
|
||||
|
||||
Reference in New Issue
Block a user