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",
|
||||||
"mode_verbose",
|
"mode_verbose",
|
||||||
"webhook_url",
|
"webhook_url",
|
||||||
"webhook_mapping",
|
"webhook_mapping_body",
|
||||||
|
"webhook_mapping_headers",
|
||||||
"send_once",
|
"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)
|
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:
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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},
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
30
schema.yml
30
schema.yml
@ -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
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user