Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

events: add configurable headers to webhooks #13602

Merged
merged 3 commits into from
Mar 21, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion authentik/events/api/notification_transports.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,8 @@ class Meta:
"mode",
"mode_verbose",
"webhook_url",
"webhook_mapping",
"webhook_mapping_body",
"webhook_mapping_headers",
"send_once",
]

Expand Down
Original file line number Diff line number Diff line change
@@ -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",
),
),
]
41 changes: 35 additions & 6 deletions authentik/events/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -336,8 +336,27 @@
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,
Expand All @@ -360,8 +379,8 @@

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,
Expand All @@ -380,9 +399,18 @@
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:

Check warning on line 403 in authentik/events/models.py

View check run for this annotation

Codecov / codecov/patch

authentik/events/models.py#L402-L403

Added lines #L402 - L403 were not covered by tests
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(

Check warning on line 412 in authentik/events/models.py

View check run for this annotation

Codecov / codecov/patch

authentik/events/models.py#L411-L412

Added lines #L411 - L412 were not covered by tests
self.webhook_mapping_headers.evaluate(
user=notification.user,
request=None,
notification=notification,
Expand All @@ -392,6 +420,7 @@
response = get_http_session().post(
self.webhook_url,
json=default_body,
headers=headers,
)
response.raise_for_status()
except RequestException as exc:
Expand Down
2 changes: 1 addition & 1 deletion authentik/events/tests/test_notifications.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
9 changes: 7 additions & 2 deletions authentik/events/tests/test_transports.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,20 +60,25 @@

def test_transport_webhook_mapping(self):
"""Test webhook transport with custom mapping"""
mapping = NotificationWebhookMapping.objects.create(
mapping_body = NotificationWebhookMapping.objects.create(

Check warning on line 63 in authentik/events/tests/test_transports.py

View check run for this annotation

Codecov / codecov/patch

authentik/events/tests/test_transports.py#L63

Added line #L63 was not covered by tests
name=generate_id(), expression="return request.user"
)
mapping_headers = NotificationWebhookMapping.objects.create(

Check warning on line 66 in authentik/events/tests/test_transports.py

View check run for this annotation

Codecov / codecov/patch

authentik/events/tests/test_transports.py#L66

Added line #L66 was not covered by tests
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")

Check warning on line 81 in authentik/events/tests/test_transports.py

View check run for this annotation

Codecov / codecov/patch

authentik/events/tests/test_transports.py#L81

Added line #L81 was not covered by tests
self.assertJSONEqual(
mocker.request_history[0].body.decode(),
{"email": self.user.email, "pk": self.user.pk, "username": self.user.username},
Expand Down
10 changes: 8 additions & 2 deletions blueprints/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
30 changes: 27 additions & 3 deletions schema.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
47 changes: 40 additions & 7 deletions web/src/admin/events/TransportForm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,15 +66,15 @@ 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)}"
class="pf-c-form-control"
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);
Expand Down Expand Up @@ -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"
Expand All @@ -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 (
Expand All @@ -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>
Expand Down
Loading