diff --git a/authentik/events/api/notification_rules.py b/authentik/events/api/notification_rules.py index 4b183b9f6c..8af00d09d3 100644 --- a/authentik/events/api/notification_rules.py +++ b/authentik/events/api/notification_rules.py @@ -11,7 +11,7 @@ from authentik.events.models import NotificationRule class NotificationRuleSerializer(ModelSerializer): """NotificationRule Serializer""" - group_obj = GroupSerializer(read_only=True, source="group") + destination_group_obj = GroupSerializer(read_only=True, source="destination_group") class Meta: model = NotificationRule @@ -20,8 +20,9 @@ class NotificationRuleSerializer(ModelSerializer): "name", "transports", "severity", - "group", - "group_obj", + "destination_group", + "destination_group_obj", + "destination_event_user", ] @@ -30,6 +31,6 @@ class NotificationRuleViewSet(UsedByMixin, ModelViewSet): queryset = NotificationRule.objects.all() serializer_class = NotificationRuleSerializer - filterset_fields = ["name", "severity", "group__name"] + filterset_fields = ["name", "severity", "destination_group__name"] ordering = ["name"] - search_fields = ["name", "group__name"] + search_fields = ["name", "destination_group__name"] diff --git a/authentik/events/migrations/0010_rename_group_notificationrule_destination_group_and_more.py b/authentik/events/migrations/0010_rename_group_notificationrule_destination_group_and_more.py new file mode 100644 index 0000000000..f85d81f7f9 --- /dev/null +++ b/authentik/events/migrations/0010_rename_group_notificationrule_destination_group_and_more.py @@ -0,0 +1,26 @@ +# Generated by Django 5.1.11 on 2025-06-16 23:21 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_events", "0009_remove_notificationtransport_webhook_mapping_and_more"), + ] + + operations = [ + migrations.RenameField( + model_name="notificationrule", + old_name="group", + new_name="destination_group", + ), + migrations.AddField( + model_name="notificationrule", + name="destination_event_user", + field=models.BooleanField( + default=False, + help_text="When enabled, notification will be sent to user the user that triggered the event.When destination_group is configured, notification is sent to both.", + ), + ), + ] diff --git a/authentik/events/models.py b/authentik/events/models.py index 83d87da916..5d4646c561 100644 --- a/authentik/events/models.py +++ b/authentik/events/models.py @@ -1,10 +1,12 @@ """authentik events models""" +from collections.abc import Generator from datetime import timedelta from difflib import get_close_matches from functools import lru_cache from inspect import currentframe from smtplib import SMTPException +from typing import Any from uuid import uuid4 from django.apps import apps @@ -547,7 +549,7 @@ class NotificationRule(SerializerModel, PolicyBindingModel): default=NotificationSeverity.NOTICE, help_text=_("Controls which severity level the created notifications will have."), ) - group = models.ForeignKey( + destination_group = models.ForeignKey( Group, help_text=_( "Define which group of users this notification should be sent and shown to. " @@ -557,6 +559,19 @@ class NotificationRule(SerializerModel, PolicyBindingModel): blank=True, on_delete=models.SET_NULL, ) + destination_event_user = models.BooleanField( + default=False, + help_text=_( + "When enabled, notification will be sent to user the user that triggered the event." + "When destination_group is configured, notification is sent to both." + ), + ) + + def destination_users(self, event: Event) -> Generator[User, Any]: + if self.destination_event_user and event.user.get("pk"): + yield User(pk=event.user.get("pk")) + if self.destination_group: + yield from self.destination_group.users.all() @property def serializer(self) -> type[Serializer]: diff --git a/authentik/events/tasks.py b/authentik/events/tasks.py index d923b068b5..d8212c561c 100644 --- a/authentik/events/tasks.py +++ b/authentik/events/tasks.py @@ -68,14 +68,10 @@ def event_trigger_handler(event_uuid: str, trigger_name: str): if not result.passing: return - if not trigger.group: - LOGGER.debug("e(trigger): trigger has no group", trigger=trigger) - return - LOGGER.debug("e(trigger): event trigger matched", trigger=trigger) # Create the notification objects for transport in trigger.transports.all(): - for user in trigger.group.users.all(): + for user in trigger.destination_users(event): LOGGER.debug("created notification") notification_transport.apply_async( args=[ diff --git a/authentik/events/tests/test_notifications.py b/authentik/events/tests/test_notifications.py index 6fd6050cc5..44187c4279 100644 --- a/authentik/events/tests/test_notifications.py +++ b/authentik/events/tests/test_notifications.py @@ -6,6 +6,7 @@ from django.urls import reverse from rest_framework.test import APITestCase from authentik.core.models import Group, User +from authentik.core.tests.utils import create_test_user from authentik.events.models import ( Event, EventAction, @@ -34,7 +35,7 @@ class TestEventsNotifications(APITestCase): def test_trigger_empty(self): """Test trigger without any policies attached""" transport = NotificationTransport.objects.create(name=generate_id()) - trigger = NotificationRule.objects.create(name=generate_id(), group=self.group) + trigger = NotificationRule.objects.create(name=generate_id(), destination_group=self.group) trigger.transports.add(transport) trigger.save() @@ -46,7 +47,7 @@ class TestEventsNotifications(APITestCase): def test_trigger_single(self): """Test simple transport triggering""" transport = NotificationTransport.objects.create(name=generate_id()) - trigger = NotificationRule.objects.create(name=generate_id(), group=self.group) + trigger = NotificationRule.objects.create(name=generate_id(), destination_group=self.group) trigger.transports.add(transport) trigger.save() matcher = EventMatcherPolicy.objects.create( @@ -59,6 +60,25 @@ class TestEventsNotifications(APITestCase): Event.new(EventAction.CUSTOM_PREFIX).save() self.assertEqual(execute_mock.call_count, 1) + def test_trigger_event_user(self): + """Test trigger with event user""" + user = create_test_user() + transport = NotificationTransport.objects.create(name=generate_id()) + trigger = NotificationRule.objects.create(name=generate_id(), destination_event_user=True) + trigger.transports.add(transport) + trigger.save() + matcher = EventMatcherPolicy.objects.create( + name="matcher", action=EventAction.CUSTOM_PREFIX + ) + PolicyBinding.objects.create(target=trigger, policy=matcher, order=0) + + execute_mock = MagicMock() + with patch("authentik.events.models.NotificationTransport.send", execute_mock): + Event.new(EventAction.CUSTOM_PREFIX).set_user(user).save() + self.assertEqual(execute_mock.call_count, 1) + notification: Notification = execute_mock.call_args[0][0] + self.assertEqual(notification.user, user) + def test_trigger_no_group(self): """Test trigger without group""" trigger = NotificationRule.objects.create(name=generate_id()) @@ -76,7 +96,7 @@ class TestEventsNotifications(APITestCase): """Test Policy error which would cause recursion""" transport = NotificationTransport.objects.create(name=generate_id()) NotificationRule.objects.filter(name__startswith="default").delete() - trigger = NotificationRule.objects.create(name=generate_id(), group=self.group) + trigger = NotificationRule.objects.create(name=generate_id(), destination_group=self.group) trigger.transports.add(transport) trigger.save() matcher = EventMatcherPolicy.objects.create( @@ -99,7 +119,7 @@ class TestEventsNotifications(APITestCase): transport = NotificationTransport.objects.create(name=generate_id(), send_once=True) NotificationRule.objects.filter(name__startswith="default").delete() - trigger = NotificationRule.objects.create(name=generate_id(), group=self.group) + trigger = NotificationRule.objects.create(name=generate_id(), destination_group=self.group) trigger.transports.add(transport) trigger.save() matcher = EventMatcherPolicy.objects.create( @@ -123,7 +143,7 @@ class TestEventsNotifications(APITestCase): 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) + trigger = NotificationRule.objects.create(name=generate_id(), destination_group=self.group) trigger.transports.add(transport) matcher = EventMatcherPolicy.objects.create( name="matcher", action=EventAction.CUSTOM_PREFIX diff --git a/blueprints/schema.json b/blueprints/schema.json index d42896098b..3d59031ce9 100644 --- a/blueprints/schema.json +++ b/blueprints/schema.json @@ -6628,11 +6628,16 @@ "title": "Severity", "description": "Controls which severity level the created notifications will have." }, - "group": { + "destination_group": { "type": "string", "format": "uuid", - "title": "Group", + "title": "Destination group", "description": "Define which group of users this notification should be sent and shown to. If left empty, Notification won't ben sent." + }, + "destination_event_user": { + "type": "boolean", + "title": "Destination event user", + "description": "When enabled, notification will be sent to user the user that triggered the event.When destination_group is configured, notification is sent to both." } }, "required": [] diff --git a/schema.yml b/schema.yml index d8c3990ecd..fad722a9d0 100644 --- a/schema.yml +++ b/schema.yml @@ -7787,7 +7787,7 @@ paths: description: NotificationRule Viewset parameters: - in: query - name: group__name + name: destination_group__name schema: type: string - in: query @@ -48831,18 +48831,23 @@ components: - $ref: '#/components/schemas/SeverityEnum' description: Controls which severity level the created notifications will have. - group: + destination_group: type: string format: uuid nullable: true description: Define which group of users this notification should be sent and shown to. If left empty, Notification won't ben sent. - group_obj: + destination_group_obj: allOf: - $ref: '#/components/schemas/Group' readOnly: true + destination_event_user: + type: boolean + description: When enabled, notification will be sent to user the user that + triggered the event.When destination_group is configured, notification + is sent to both. required: - - group_obj + - destination_group_obj - name - pk NotificationRuleRequest: @@ -48865,12 +48870,17 @@ components: - $ref: '#/components/schemas/SeverityEnum' description: Controls which severity level the created notifications will have. - group: + destination_group: type: string format: uuid nullable: true description: Define which group of users this notification should be sent and shown to. If left empty, Notification won't ben sent. + destination_event_user: + type: boolean + description: When enabled, notification will be sent to user the user that + triggered the event.When destination_group is configured, notification + is sent to both. required: - name NotificationTransport: @@ -53940,12 +53950,17 @@ components: - $ref: '#/components/schemas/SeverityEnum' description: Controls which severity level the created notifications will have. - group: + destination_group: type: string format: uuid nullable: true description: Define which group of users this notification should be sent and shown to. If left empty, Notification won't ben sent. + destination_event_user: + type: boolean + description: When enabled, notification will be sent to user the user that + triggered the event.When destination_group is configured, notification + is sent to both. PatchedNotificationTransportRequest: type: object description: NotificationTransport Serializer diff --git a/web/src/admin/events/RuleForm.ts b/web/src/admin/events/RuleForm.ts index bcdf2dfb2d..cffe76d280 100644 --- a/web/src/admin/events/RuleForm.ts +++ b/web/src/admin/events/RuleForm.ts @@ -66,7 +66,7 @@ export class RuleForm extends ModelForm { required /> - + => { const args: CoreGroupsListRequest = { @@ -86,14 +86,44 @@ export class RuleForm extends ModelForm { return group?.pk; }} .selected=${(group: Group): boolean => { - return group.pk === this.instance?.group; + return group.pk === this.instance?.destinationGroup; }} blankable > +

+ ${msg("Select the group of users which the alerts are sent to. ")} +

${msg( - "Select the group of users which the alerts are sent to. If no group is selected the rule is disabled.", + "If no group is selected and 'Send notification to event user' is disabled the rule is disabled. ", + )} +

+
+ + +

+ ${msg( + "When enabled, notification will be sent to the user that triggered the event in addition to any users in the group above. The event user will always be the first user, to send a notification only to the event user enabled 'Send once' in the notification transport.", + )} +

+

+ ${msg( + "If no group is selected and 'Send notification to event user' is disabled the rule is disabled. ", )}

diff --git a/web/src/admin/events/RuleListPage.ts b/web/src/admin/events/RuleListPage.ts index eae71b7094..8c9a9f3fdd 100644 --- a/web/src/admin/events/RuleListPage.ts +++ b/web/src/admin/events/RuleListPage.ts @@ -3,6 +3,7 @@ import "@goauthentik/admin/policies/BoundPoliciesList"; import "@goauthentik/admin/rbac/ObjectPermissionModal"; import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; import { severityToLabel } from "@goauthentik/common/labels"; +import "@goauthentik/components/ak-status-label"; import "@goauthentik/elements/buttons/SpinnerButton"; import "@goauthentik/elements/forms/DeleteBulkForm"; import "@goauthentik/elements/forms/ModalForm"; @@ -51,6 +52,7 @@ export class RuleListPage extends TablePage { columns(): TableColumn[] { return [ + new TableColumn(msg("Enabled")), new TableColumn(msg("Name"), "name"), new TableColumn(msg("Severity"), "severity"), new TableColumn(msg("Sent to group"), "group"), @@ -81,12 +83,16 @@ export class RuleListPage extends TablePage { } row(item: NotificationRule): TemplateResult[] { + const enabled = !!item.destinationGroupObj || item.destinationEventUser; return [ + html``, html`${item.name}`, html`${severityToLabel(item.severity)}`, - html`${item.groupObj - ? html`${item.groupObj.name}` - : msg("None (rule disabled)")}`, + html`${item.destinationGroupObj + ? html`${item.destinationGroupObj.name}` + : msg("-")}`, html` ${msg("Update")} ${msg("Update Notification Rule")}