From 59263ae6786392a0f34b0dd3a9baead316b37b46 Mon Sep 17 00:00:00 2001 From: "Jens L." Date: Sat, 14 Mar 2026 21:01:01 +0100 Subject: [PATCH] events: add option to configure webhook CA (#20823) * events: add option to configure webhook CA Signed-off-by: Jens Langhammer * add tests Signed-off-by: Jens Langhammer * add docs Signed-off-by: Jens Langhammer * Update website/docs/sys-mgmt/events/transports.md Co-authored-by: Dewi Roberts Signed-off-by: Jens L. --------- Signed-off-by: Jens Langhammer Signed-off-by: Jens L. Co-authored-by: Dewi Roberts --- .../events/api/notification_transports.py | 1 + .../0017_notificationtransport_webhook_ca.py | 26 ++++++++++ authentik/events/models.py | 50 +++++++++++++------ authentik/events/tests/test_transports.py | 32 ++++++++++++ authentik/outposts/docker_tls.py | 6 +++ blueprints/schema.json | 6 +++ schema.yml | 18 +++++++ web/src/admin/events/TransportForm.ts | 15 ++++++ website/docs/sys-mgmt/events/transports.md | 4 ++ 9 files changed, 143 insertions(+), 15 deletions(-) create mode 100644 authentik/events/migrations/0017_notificationtransport_webhook_ca.py diff --git a/authentik/events/api/notification_transports.py b/authentik/events/api/notification_transports.py index b1dfcf556d..1e2486ae20 100644 --- a/authentik/events/api/notification_transports.py +++ b/authentik/events/api/notification_transports.py @@ -63,6 +63,7 @@ class NotificationTransportSerializer(ModelSerializer): "mode", "mode_verbose", "webhook_url", + "webhook_ca", "webhook_mapping_body", "webhook_mapping_headers", "email_subject_prefix", diff --git a/authentik/events/migrations/0017_notificationtransport_webhook_ca.py b/authentik/events/migrations/0017_notificationtransport_webhook_ca.py new file mode 100644 index 0000000000..d064f84bd4 --- /dev/null +++ b/authentik/events/migrations/0017_notificationtransport_webhook_ca.py @@ -0,0 +1,26 @@ +# Generated by Django 5.2.12 on 2026-03-10 10:40 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_crypto", "0006_certificatekeypair_cert_expiry_and_more"), + ("authentik_events", "0016_alter_event_action"), + ] + + operations = [ + migrations.AddField( + model_name="notificationtransport", + name="webhook_ca", + field=models.ForeignKey( + default=None, + help_text="When set, the selected ceritifcate is used to validate the certificate of the webhook server.", + null=True, + on_delete=django.db.models.deletion.SET_DEFAULT, + to="authentik_crypto.certificatekeypair", + ), + ), + ] diff --git a/authentik/events/models.py b/authentik/events/models.py index 11bcf6f568..e0020f833d 100644 --- a/authentik/events/models.py +++ b/authentik/events/models.py @@ -28,6 +28,7 @@ from authentik.core.middleware import ( SESSION_KEY_IMPERSONATE_USER, ) from authentik.core.models import ExpiringModel, Group, PropertyMapping, User +from authentik.crypto.models import CertificateKeyPair from authentik.events.context_processors.base import get_context_processors from authentik.events.utils import ( cleanse_dict, @@ -41,6 +42,7 @@ from authentik.lib.sentry import SentryIgnoredException from authentik.lib.utils.errors import exception_to_dict from authentik.lib.utils.http import get_http_session from authentik.lib.utils.time import timedelta_from_string +from authentik.outposts.docker_tls import DockerInlineTLS from authentik.policies.models import PolicyBindingModel from authentik.root.middleware import ClientIPMiddleware from authentik.root.ws.consumer import build_user_group @@ -326,6 +328,16 @@ class NotificationTransport(TasksModel, SerializerModel): email_template = models.TextField(default=EmailTemplates.EVENT_NOTIFICATION) webhook_url = models.TextField(blank=True, validators=[DomainlessURLValidator()]) + webhook_ca = models.ForeignKey( + CertificateKeyPair, + null=True, + default=None, + on_delete=models.SET_DEFAULT, + help_text=_( + "When set, the selected ceritifcate is used to " + "validate the certificate of the webhook server." + ), + ) webhook_mapping_body = models.ForeignKey( "NotificationWebhookMapping", on_delete=models.SET_DEFAULT, @@ -409,21 +421,29 @@ class NotificationTransport(TasksModel, SerializerModel): notification=notification, ) ) - try: - response = get_http_session().post( - self.webhook_url, - json=default_body, - headers=headers, - ) - response.raise_for_status() - except RequestException as exc: - raise NotificationTransportError( - exc.response.text if exc.response else str(exc) - ) from exc - return [ - response.status_code, - response.text, - ] + + def send(**kwargs): + try: + response = get_http_session().post( + self.webhook_url, + json=default_body, + headers=headers, + **kwargs, + ) + response.raise_for_status() + except RequestException as exc: + raise NotificationTransportError( + exc.response.text if exc.response else str(exc) + ) from exc + return [ + response.status_code, + response.text, + ] + + if self.webhook_ca: + with DockerInlineTLS(self.webhook_ca, authentication_kp=None) as tls: + return send(verify=tls.ca_cert) + return send() def send_webhook_slack(self, notification: Notification) -> list[str]: """Send notification to slack or slack-compatible endpoints""" diff --git a/authentik/events/tests/test_transports.py b/authentik/events/tests/test_transports.py index 1f4add057c..5a0379c945 100644 --- a/authentik/events/tests/test_transports.py +++ b/authentik/events/tests/test_transports.py @@ -10,6 +10,7 @@ from requests_mock import Mocker from authentik import authentik_full_version from authentik.core.tests.utils import create_test_admin_user +from authentik.crypto.models import CertificateKeyPair from authentik.events.api.notification_transports import NotificationTransportSerializer from authentik.events.models import ( Event, @@ -61,6 +62,37 @@ class TestEventTransports(TestCase): }, ) + def test_transport_webhook_ca_invalid_unset(self): + """Test webhook transport""" + transport: NotificationTransport = NotificationTransport.objects.create( + name=generate_id(), + mode=TransportMode.WEBHOOK, + webhook_url="https://localhost:1234/test", + ) + with Mocker() as mocker: + mocker.post("https://localhost:1234/test") + transport.send(self.notification) + self.assertEqual(mocker.call_count, 1) + self.assertTrue(mocker.request_history[0].verify) + + def test_transport_webhook_ca(self): + """Test webhook transport""" + kp = CertificateKeyPair.objects.create( + name=generate_id(), + certificate_data="foo", + ) + transport: NotificationTransport = NotificationTransport.objects.create( + name=generate_id(), + mode=TransportMode.WEBHOOK, + webhook_url="https://localhost:1234/test", + webhook_ca=kp, + ) + with Mocker() as mocker: + mocker.post("https://localhost:1234/test") + transport.send(self.notification) + self.assertEqual(mocker.call_count, 1) + self.assertIsNotNone(mocker.request_history[0].verify) + def test_transport_webhook_mapping(self): """Test webhook transport with custom mapping""" mapping_body = NotificationWebhookMapping.objects.create( diff --git a/authentik/outposts/docker_tls.py b/authentik/outposts/docker_tls.py index f4e9e641c6..258dc8a94d 100644 --- a/authentik/outposts/docker_tls.py +++ b/authentik/outposts/docker_tls.py @@ -27,6 +27,12 @@ class DockerInlineTLS: self.authentication_kp = authentication_kp self._paths = [] + def __enter__(self): + return self.write() + + def __exit__(self, exc_type, exc, tb): + self.cleanup() + def write_file(self, name: str, contents: str) -> str: """Wrapper for mkstemp that uses fdopen""" path = Path(gettempdir(), name) diff --git a/blueprints/schema.json b/blueprints/schema.json index 3cb3594caf..731544146c 100644 --- a/blueprints/schema.json +++ b/blueprints/schema.json @@ -8236,6 +8236,12 @@ "type": "string", "title": "Webhook url" }, + "webhook_ca": { + "type": "string", + "format": "uuid", + "title": "Webhook ca", + "description": "When set, the selected ceritifcate is used to validate the certificate of the webhook server." + }, "webhook_mapping_body": { "type": "string", "format": "uuid", diff --git a/schema.yml b/schema.yml index c3df526e42..9cf5f5898a 100644 --- a/schema.yml +++ b/schema.yml @@ -43559,6 +43559,12 @@ components: webhook_url: type: string format: uri + webhook_ca: + type: string + format: uuid + nullable: true + description: When set, the selected ceritifcate is used to validate the + certificate of the webhook server. webhook_mapping_body: type: string format: uuid @@ -43602,6 +43608,12 @@ components: webhook_url: type: string format: uri + webhook_ca: + type: string + format: uuid + nullable: true + description: When set, the selected ceritifcate is used to validate the + certificate of the webhook server. webhook_mapping_body: type: string format: uuid @@ -49312,6 +49324,12 @@ components: webhook_url: type: string format: uri + webhook_ca: + type: string + format: uuid + nullable: true + description: When set, the selected ceritifcate is used to validate the + certificate of the webhook server. webhook_mapping_body: type: string format: uuid diff --git a/web/src/admin/events/TransportForm.ts b/web/src/admin/events/TransportForm.ts index c21a7258cd..9c65933203 100644 --- a/web/src/admin/events/TransportForm.ts +++ b/web/src/admin/events/TransportForm.ts @@ -3,6 +3,7 @@ import "#components/ak-switch-input"; import "#elements/forms/HorizontalFormElement"; import "#elements/forms/Radio"; import "#elements/forms/SearchSelect/index"; +import "#admin/common/ak-crypto-certificate-search"; import { DEFAULT_CONFIG } from "#common/api/config"; @@ -142,6 +143,20 @@ export class TransportForm extends ModelForm { ?required=${this.showWebhook} > + + +

+ ${msg( + "Keypair used to validate the certificate of the webhook endpoint. When not configured, the standard CA bundle is used.", + )} +

+