Repository: goauthentik/authentik

Issue:
Property mapping for web UI notifications.
**Is your feature request related to a problem? Please describe.**
I am implementing login notifications using the Generic webhook  for the Policies `Policy authentik-core-login` and `Policy authentik-core-login-failed` with a webhook mapping.

The current behavior also sends the raw unformulated event json to the web notification for the user group targeted by the notifications.

**Describe the solution you'd like**
Add the web notifications as a transport to be able to manually apply a property mapping to web notifications so it formats nicely like just like the webhook.

**Describe alternatives you've considered**
A method of excluding the property mapped webhook from the web ui notifications.

**Additional context**
![image](https://user-images.githubusercontent.com/12742064/170913487-7f7788ae-280f-47a4-aa55-e0dd414a587e.png)
![image](https://user-images.githubusercontent.com/12742064/170913627-1182d578-bfe3-4726-908b-27e91a7c7f57.png)

Property Mapping
```python
try:
    event_user = notification.event.context["username"]
except:
    event_user = notification.event.user["username"]

if notification.event.action == "login_failed":
    priority = 7
    severity = "warning"
elif not ak_is_group_member( ak_user_by(username=event_user), name="gotify-users" ):
    priority = 0
    severity = notification.severity
else:
    priority = 1
    severity = notification.severity

geo_uri = f"geo:{notification.event.context['geo']['lat']},{notification.event.context['geo']['long']}?q={notification.event.context['geo']['lat']},{notification.event.context['geo']['long']}"

title = f"{severity} from authentik {notification.event.action.replace('_', ' ')}".capitalize()

message = f"New {notification.event.action.replace('_', ' ')} for {event_user} was detected coming from {notification.event.context['geo']['city']} {notification.event.context['geo']['country']} from the IP address: {str(notification.event.client_ip)}".capitalize()

g_notif = {
    "title": title,
    "message": message,
    "priority": priority,
    "extras": { "client::notification": { "click": { "url": geo_uri } }},
}

return g_notif
```



Discussion:

Resolving patch:
diff --git a/authentik/events/migrations/0002_alter_notificationtransport_mode.py b/authentik/events/migrations/0002_alter_notificationtransport_mode.py
new file mode 100644
--- /dev/null
+++ b/authentik/events/migrations/0002_alter_notificationtransport_mode.py
@@ -0,0 +1,49 @@
+# Generated by Django 4.0.4 on 2022-05-30 18:08
+from django.apps.registry import Apps
+from django.db import migrations, models
+from django.db.backends.base.schema import BaseDatabaseSchemaEditor
+
+from authentik.events.models import TransportMode
+
+
+def notify_local_transport(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
+    db_alias = schema_editor.connection.alias
+    NotificationTransport = apps.get_model("authentik_events", "NotificationTransport")
+    NotificationRule = apps.get_model("authentik_events", "NotificationRule")
+
+    local_transport, _ = NotificationTransport.objects.using(db_alias).update_or_create(
+        name="default-local-transport",
+        defaults={"mode": TransportMode.LOCAL},
+    )
+
+    for trigger in NotificationRule.objects.using(db_alias).filter(
+        name__in=[
+            "default-notify-configuration-error",
+            "default-notify-exception",
+            "default-notify-update",
+        ]
+    ):
+        trigger.transports.add(local_transport)
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("authentik_events", "0001_squashed_0019_alter_notificationtransport_webhook_url"),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name="notificationtransport",
+            name="mode",
+            field=models.TextField(
+                choices=[
+                    ("local", "authentik inbuilt notifications"),
+                    ("webhook", "Generic Webhook"),
+                    ("webhook_slack", "Slack Webhook (Slack/Discord)"),
+                    ("email", "Email"),
+                ]
+            ),
+        ),
+        migrations.RunPython(notify_local_transport),
+    ]
diff --git a/authentik/events/models.py b/authentik/events/models.py
--- a/authentik/events/models.py
+++ b/authentik/events/models.py
@@ -289,6 +289,7 @@ class Meta:
 class TransportMode(models.TextChoices):
     """Modes that a notification transport can send a notification"""
 
+    LOCAL = "local", _("authentik inbuilt notifications")
     WEBHOOK = "webhook", _("Generic Webhook")
     WEBHOOK_SLACK = "webhook_slack", _("Slack Webhook (Slack/Discord)")
     EMAIL = "email", _("Email")
@@ -315,6 +316,8 @@ class NotificationTransport(models.Model):
 
     def send(self, notification: "Notification") -> list[str]:
         """Send notification to user, called from async task"""
+        if self.mode == TransportMode.LOCAL:
+            return self.send_local(notification)
         if self.mode == TransportMode.WEBHOOK:
             return self.send_webhook(notification)
         if self.mode == TransportMode.WEBHOOK_SLACK:
@@ -323,6 +326,17 @@ def send(self, notification: "Notification") -> list[str]:
             return self.send_email(notification)
         raise ValueError(f"Invalid mode {self.mode} set")
 
+    def send_local(self, notification: "Notification") -> list[str]:
+        """Local notification delivery"""
+        if self.webhook_mapping:
+            self.webhook_mapping.evaluate(
+                user=notification.user,
+                request=None,
+                notification=notification,
+            )
+        notification.save()
+        return []
+
     def send_webhook(self, notification: "Notification") -> list[str]:
         """Send notification to generic webhook"""
         default_body = {
diff --git a/authentik/events/tasks.py b/authentik/events/tasks.py
--- a/authentik/events/tasks.py
+++ b/authentik/events/tasks.py
@@ -1,8 +1,11 @@
 """Event notification tasks"""
+from typing import Optional
+
 from django.db.models.query_utils import Q
 from guardian.shortcuts import get_anonymous_user
 from structlog.stdlib import get_logger
 
+from authentik.core.exceptions import PropertyMappingExpressionException
 from authentik.core.models import User
 from authentik.events.models import (
     Event,
@@ -39,10 +42,9 @@ def event_trigger_handler(event_uuid: str, trigger_name: str):
         LOGGER.warning("event doesn't exist yet or anymore", event_uuid=event_uuid)
         return
     event: Event = events.first()
-    triggers: NotificationRule = NotificationRule.objects.filter(name=trigger_name)
-    if not triggers.exists():
+    trigger: Optional[NotificationRule] = NotificationRule.objects.filter(name=trigger_name).first()
+    if not trigger:
         return
-    trigger = triggers.first()
 
     if "policy_uuid" in event.context:
         policy_uuid = event.context["policy_uuid"]
@@ -81,11 +83,14 @@ def event_trigger_handler(event_uuid: str, trigger_name: str):
     for transport in trigger.transports.all():
         for user in trigger.group.users.all():
             LOGGER.debug("created notification")
-            notification = Notification.objects.create(
-                severity=trigger.severity, body=event.summary, event=event, user=user
-            )
             notification_transport.apply_async(
-                args=[notification.pk, transport.pk], queue="authentik_events"
+                args=[
+                    transport.pk,
+                    str(event.pk),
+                    user.pk,
+                    str(trigger.pk),
+                ],
+                queue="authentik_events",
             )
             if transport.send_once:
                 break
@@ -97,19 +102,30 @@ def event_trigger_handler(event_uuid: str, trigger_name: str):
     retry_backoff=True,
     base=MonitoredTask,
 )
-def notification_transport(self: MonitoredTask, notification_pk: int, transport_pk: int):
+def notification_transport(
+    self: MonitoredTask, transport_pk: int, event_pk: str, user_pk: int, trigger_pk: str
+):
     """Send notification over specified transport"""
     self.save_on_success = False
     try:
-        notification: Notification = Notification.objects.filter(pk=notification_pk).first()
-        if not notification:
+        event = Event.objects.filter(pk=event_pk).first()
+        if not event:
+            return
+        user = User.objects.filter(pk=user_pk).first()
+        if not user:
+            return
+        trigger = NotificationRule.objects.filter(pk=trigger_pk).first()
+        if not trigger:
             return
+        notification = Notification(
+            severity=trigger.severity, body=event.summary, event=event, user=user
+        )
         transport = NotificationTransport.objects.filter(pk=transport_pk).first()
         if not transport:
             return
         transport.send(notification)
         self.set_status(TaskResult(TaskResultStatus.SUCCESSFUL))
-    except NotificationTransportError as exc:
+    except (NotificationTransportError, PropertyMappingExpressionException) as exc:
         self.set_status(TaskResult(TaskResultStatus.ERROR).with_error(exc))
         raise exc
 


Test patch:
diff --git a/authentik/events/tests/test_notifications.py b/authentik/events/tests/test_notifications.py
--- a/authentik/events/tests/test_notifications.py
+++ b/authentik/events/tests/test_notifications.py
@@ -11,7 +11,10 @@
     Notification,
     NotificationRule,
     NotificationTransport,
+    NotificationWebhookMapping,
+    TransportMode,
 )
+from authentik.lib.generators import generate_id
 from authentik.policies.event_matcher.models import EventMatcherPolicy
 from authentik.policies.exceptions import PolicyException
 from authentik.policies.models import PolicyBinding
@@ -105,4 +108,26 @@ def test_transport_once(self):
         execute_mock = MagicMock()
         with patch("authentik.events.models.NotificationTransport.send", execute_mock):
             Event.new(EventAction.CUSTOM_PREFIX).save()
-        self.assertEqual(Notification.objects.count(), 1)
+        self.assertEqual(execute_mock.call_count, 1)
+
+    def test_transport_mapping(self):
+        """Test transport mapping"""
+        mapping = NotificationWebhookMapping.objects.create(
+            name=generate_id(),
+            expression="""notification.body = 'foo'""",
+        )
+
+        transport = NotificationTransport.objects.create(
+            name="transport", webhook_mapping=mapping, mode=TransportMode.LOCAL
+        )
+        NotificationRule.objects.filter(name__startswith="default").delete()
+        trigger = NotificationRule.objects.create(name="trigger", group=self.group)
+        trigger.transports.add(transport)
+        matcher = EventMatcherPolicy.objects.create(
+            name="matcher", action=EventAction.CUSTOM_PREFIX
+        )
+        PolicyBinding.objects.create(target=trigger, policy=matcher, order=0)
+
+        Notification.objects.all().delete()
+        Event.new(EventAction.CUSTOM_PREFIX).save()
+        self.assertEqual(Notification.objects.first().body, "foo")