events: add option to send notifications to event user (#15083)

* events: add option to send notifications to event user

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* fix tests

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
This commit is contained in:
Jens L.
2025-06-18 13:39:56 +02:00
committed by GitHub
parent 3fa6ce2e34
commit 36c9929e1f
9 changed files with 144 additions and 30 deletions

View File

@ -11,7 +11,7 @@ from authentik.events.models import NotificationRule
class NotificationRuleSerializer(ModelSerializer): class NotificationRuleSerializer(ModelSerializer):
"""NotificationRule Serializer""" """NotificationRule Serializer"""
group_obj = GroupSerializer(read_only=True, source="group") destination_group_obj = GroupSerializer(read_only=True, source="destination_group")
class Meta: class Meta:
model = NotificationRule model = NotificationRule
@ -20,8 +20,9 @@ class NotificationRuleSerializer(ModelSerializer):
"name", "name",
"transports", "transports",
"severity", "severity",
"group", "destination_group",
"group_obj", "destination_group_obj",
"destination_event_user",
] ]
@ -30,6 +31,6 @@ class NotificationRuleViewSet(UsedByMixin, ModelViewSet):
queryset = NotificationRule.objects.all() queryset = NotificationRule.objects.all()
serializer_class = NotificationRuleSerializer serializer_class = NotificationRuleSerializer
filterset_fields = ["name", "severity", "group__name"] filterset_fields = ["name", "severity", "destination_group__name"]
ordering = ["name"] ordering = ["name"]
search_fields = ["name", "group__name"] search_fields = ["name", "destination_group__name"]

View File

@ -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.",
),
),
]

View File

@ -1,10 +1,12 @@
"""authentik events models""" """authentik events models"""
from collections.abc import Generator
from datetime import timedelta from datetime import timedelta
from difflib import get_close_matches from difflib import get_close_matches
from functools import lru_cache from functools import lru_cache
from inspect import currentframe from inspect import currentframe
from smtplib import SMTPException from smtplib import SMTPException
from typing import Any
from uuid import uuid4 from uuid import uuid4
from django.apps import apps from django.apps import apps
@ -547,7 +549,7 @@ class NotificationRule(SerializerModel, PolicyBindingModel):
default=NotificationSeverity.NOTICE, default=NotificationSeverity.NOTICE,
help_text=_("Controls which severity level the created notifications will have."), help_text=_("Controls which severity level the created notifications will have."),
) )
group = models.ForeignKey( destination_group = models.ForeignKey(
Group, Group,
help_text=_( help_text=_(
"Define which group of users this notification should be sent and shown to. " "Define which group of users this notification should be sent and shown to. "
@ -557,6 +559,19 @@ class NotificationRule(SerializerModel, PolicyBindingModel):
blank=True, blank=True,
on_delete=models.SET_NULL, 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 @property
def serializer(self) -> type[Serializer]: def serializer(self) -> type[Serializer]:

View File

@ -68,14 +68,10 @@ def event_trigger_handler(event_uuid: str, trigger_name: str):
if not result.passing: if not result.passing:
return 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) LOGGER.debug("e(trigger): event trigger matched", trigger=trigger)
# Create the notification objects # Create the notification objects
for transport in trigger.transports.all(): for transport in trigger.transports.all():
for user in trigger.group.users.all(): for user in trigger.destination_users(event):
LOGGER.debug("created notification") LOGGER.debug("created notification")
notification_transport.apply_async( notification_transport.apply_async(
args=[ args=[

View File

@ -6,6 +6,7 @@ from django.urls import reverse
from rest_framework.test import APITestCase from rest_framework.test import APITestCase
from authentik.core.models import Group, User from authentik.core.models import Group, User
from authentik.core.tests.utils import create_test_user
from authentik.events.models import ( from authentik.events.models import (
Event, Event,
EventAction, EventAction,
@ -34,7 +35,7 @@ class TestEventsNotifications(APITestCase):
def test_trigger_empty(self): def test_trigger_empty(self):
"""Test trigger without any policies attached""" """Test trigger without any policies attached"""
transport = NotificationTransport.objects.create(name=generate_id()) 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.transports.add(transport)
trigger.save() trigger.save()
@ -46,7 +47,7 @@ class TestEventsNotifications(APITestCase):
def test_trigger_single(self): def test_trigger_single(self):
"""Test simple transport triggering""" """Test simple transport triggering"""
transport = NotificationTransport.objects.create(name=generate_id()) 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.transports.add(transport)
trigger.save() trigger.save()
matcher = EventMatcherPolicy.objects.create( matcher = EventMatcherPolicy.objects.create(
@ -59,6 +60,25 @@ class TestEventsNotifications(APITestCase):
Event.new(EventAction.CUSTOM_PREFIX).save() Event.new(EventAction.CUSTOM_PREFIX).save()
self.assertEqual(execute_mock.call_count, 1) 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): def test_trigger_no_group(self):
"""Test trigger without group""" """Test trigger without group"""
trigger = NotificationRule.objects.create(name=generate_id()) trigger = NotificationRule.objects.create(name=generate_id())
@ -76,7 +96,7 @@ class TestEventsNotifications(APITestCase):
"""Test Policy error which would cause recursion""" """Test Policy error which would cause recursion"""
transport = NotificationTransport.objects.create(name=generate_id()) transport = NotificationTransport.objects.create(name=generate_id())
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(), destination_group=self.group)
trigger.transports.add(transport) trigger.transports.add(transport)
trigger.save() trigger.save()
matcher = EventMatcherPolicy.objects.create( matcher = EventMatcherPolicy.objects.create(
@ -99,7 +119,7 @@ class TestEventsNotifications(APITestCase):
transport = NotificationTransport.objects.create(name=generate_id(), send_once=True) transport = NotificationTransport.objects.create(name=generate_id(), send_once=True)
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(), destination_group=self.group)
trigger.transports.add(transport) trigger.transports.add(transport)
trigger.save() trigger.save()
matcher = EventMatcherPolicy.objects.create( matcher = EventMatcherPolicy.objects.create(
@ -123,7 +143,7 @@ class TestEventsNotifications(APITestCase):
name=generate_id(), webhook_mapping_body=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(), destination_group=self.group)
trigger.transports.add(transport) trigger.transports.add(transport)
matcher = EventMatcherPolicy.objects.create( matcher = EventMatcherPolicy.objects.create(
name="matcher", action=EventAction.CUSTOM_PREFIX name="matcher", action=EventAction.CUSTOM_PREFIX

View File

@ -6628,11 +6628,16 @@
"title": "Severity", "title": "Severity",
"description": "Controls which severity level the created notifications will have." "description": "Controls which severity level the created notifications will have."
}, },
"group": { "destination_group": {
"type": "string", "type": "string",
"format": "uuid", "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." "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": [] "required": []

View File

@ -7787,7 +7787,7 @@ paths:
description: NotificationRule Viewset description: NotificationRule Viewset
parameters: parameters:
- in: query - in: query
name: group__name name: destination_group__name
schema: schema:
type: string type: string
- in: query - in: query
@ -48831,18 +48831,23 @@ components:
- $ref: '#/components/schemas/SeverityEnum' - $ref: '#/components/schemas/SeverityEnum'
description: Controls which severity level the created notifications will description: Controls which severity level the created notifications will
have. have.
group: destination_group:
type: string type: string
format: uuid format: uuid
nullable: true nullable: true
description: Define which group of users this notification should be sent description: Define which group of users this notification should be sent
and shown to. If left empty, Notification won't ben sent. and shown to. If left empty, Notification won't ben sent.
group_obj: destination_group_obj:
allOf: allOf:
- $ref: '#/components/schemas/Group' - $ref: '#/components/schemas/Group'
readOnly: true 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: required:
- group_obj - destination_group_obj
- name - name
- pk - pk
NotificationRuleRequest: NotificationRuleRequest:
@ -48865,12 +48870,17 @@ components:
- $ref: '#/components/schemas/SeverityEnum' - $ref: '#/components/schemas/SeverityEnum'
description: Controls which severity level the created notifications will description: Controls which severity level the created notifications will
have. have.
group: destination_group:
type: string type: string
format: uuid format: uuid
nullable: true nullable: true
description: Define which group of users this notification should be sent description: Define which group of users this notification should be sent
and shown to. If left empty, Notification won't ben 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: required:
- name - name
NotificationTransport: NotificationTransport:
@ -53940,12 +53950,17 @@ components:
- $ref: '#/components/schemas/SeverityEnum' - $ref: '#/components/schemas/SeverityEnum'
description: Controls which severity level the created notifications will description: Controls which severity level the created notifications will
have. have.
group: destination_group:
type: string type: string
format: uuid format: uuid
nullable: true nullable: true
description: Define which group of users this notification should be sent description: Define which group of users this notification should be sent
and shown to. If left empty, Notification won't ben 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: PatchedNotificationTransportRequest:
type: object type: object
description: NotificationTransport Serializer description: NotificationTransport Serializer

View File

@ -66,7 +66,7 @@ export class RuleForm extends ModelForm<NotificationRule, string> {
required required
/> />
</ak-form-element-horizontal> </ak-form-element-horizontal>
<ak-form-element-horizontal label=${msg("Group")} name="group"> <ak-form-element-horizontal label=${msg("Group")} name="destinationGroup">
<ak-search-select <ak-search-select
.fetchObjects=${async (query?: string): Promise<Group[]> => { .fetchObjects=${async (query?: string): Promise<Group[]> => {
const args: CoreGroupsListRequest = { const args: CoreGroupsListRequest = {
@ -86,14 +86,44 @@ export class RuleForm extends ModelForm<NotificationRule, string> {
return group?.pk; return group?.pk;
}} }}
.selected=${(group: Group): boolean => { .selected=${(group: Group): boolean => {
return group.pk === this.instance?.group; return group.pk === this.instance?.destinationGroup;
}} }}
blankable blankable
> >
</ak-search-select> </ak-search-select>
<p class="pf-c-form__helper-text">
${msg("Select the group of users which the alerts are sent to. ")}
</p>
<p class="pf-c-form__helper-text"> <p class="pf-c-form__helper-text">
${msg( ${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. ",
)}
</p>
</ak-form-element-horizontal>
<ak-form-element-horizontal name="destinationEventUser">
<label class="pf-c-switch">
<input
class="pf-c-switch__input"
type="checkbox"
?checked=${this.instance?.destinationEventUser ?? false}
/>
<span class="pf-c-switch__toggle">
<span class="pf-c-switch__toggle-icon">
<i class="fas fa-check" aria-hidden="true"></i>
</span>
</span>
<span class="pf-c-switch__label"
>${msg("Send notification to event user")}</span
>
</label>
<p class="pf-c-form__helper-text">
${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.",
)}
</p>
<p class="pf-c-form__helper-text">
${msg(
"If no group is selected and 'Send notification to event user' is disabled the rule is disabled. ",
)} )}
</p> </p>
</ak-form-element-horizontal> </ak-form-element-horizontal>

View File

@ -3,6 +3,7 @@ import "@goauthentik/admin/policies/BoundPoliciesList";
import "@goauthentik/admin/rbac/ObjectPermissionModal"; import "@goauthentik/admin/rbac/ObjectPermissionModal";
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { severityToLabel } from "@goauthentik/common/labels"; import { severityToLabel } from "@goauthentik/common/labels";
import "@goauthentik/components/ak-status-label";
import "@goauthentik/elements/buttons/SpinnerButton"; import "@goauthentik/elements/buttons/SpinnerButton";
import "@goauthentik/elements/forms/DeleteBulkForm"; import "@goauthentik/elements/forms/DeleteBulkForm";
import "@goauthentik/elements/forms/ModalForm"; import "@goauthentik/elements/forms/ModalForm";
@ -51,6 +52,7 @@ export class RuleListPage extends TablePage<NotificationRule> {
columns(): TableColumn[] { columns(): TableColumn[] {
return [ return [
new TableColumn(msg("Enabled")),
new TableColumn(msg("Name"), "name"), new TableColumn(msg("Name"), "name"),
new TableColumn(msg("Severity"), "severity"), new TableColumn(msg("Severity"), "severity"),
new TableColumn(msg("Sent to group"), "group"), new TableColumn(msg("Sent to group"), "group"),
@ -81,12 +83,16 @@ export class RuleListPage extends TablePage<NotificationRule> {
} }
row(item: NotificationRule): TemplateResult[] { row(item: NotificationRule): TemplateResult[] {
const enabled = !!item.destinationGroupObj || item.destinationEventUser;
return [ return [
html`<ak-status-label type="warning" ?good=${enabled}></ak-status-label>`,
html`${item.name}`, html`${item.name}`,
html`${severityToLabel(item.severity)}`, html`${severityToLabel(item.severity)}`,
html`${item.groupObj html`${item.destinationGroupObj
? html`<a href="#/identity/groups/${item.groupObj.pk}">${item.groupObj.name}</a>` ? html`<a href="#/identity/groups/${item.destinationGroupObj.pk}"
: msg("None (rule disabled)")}`, >${item.destinationGroupObj.name}</a
>`
: msg("-")}`,
html`<ak-forms-modal> html`<ak-forms-modal>
<span slot="submit"> ${msg("Update")} </span> <span slot="submit"> ${msg("Update")} </span>
<span slot="header"> ${msg("Update Notification Rule")} </span> <span slot="header"> ${msg("Update Notification Rule")} </span>