diff --git a/authentik/api/v2/urls.py b/authentik/api/v2/urls.py index 218924eb1..0078b2b99 100644 --- a/authentik/api/v2/urls.py +++ b/authentik/api/v2/urls.py @@ -19,7 +19,10 @@ from authentik.core.api.sources import SourceViewSet from authentik.core.api.tokens import TokenViewSet from authentik.core.api.users import UserViewSet from authentik.crypto.api import CertificateKeyPairViewSet -from authentik.events.api import EventViewSet +from authentik.events.api.event import EventViewSet +from authentik.events.api.notification import NotificationViewSet +from authentik.events.api.notification_transport import NotificationTransportViewSet +from authentik.events.api.notification_trigger import NotificationTriggerViewSet from authentik.flows.api import ( FlowCacheViewSet, FlowStageBindingViewSet, @@ -37,6 +40,7 @@ from authentik.policies.api import ( PolicyViewSet, ) from authentik.policies.dummy.api import DummyPolicyViewSet +from authentik.policies.event_matcher.api import EventMatcherPolicyViewSet from authentik.policies.expiry.api import PasswordExpiryPolicyViewSet from authentik.policies.expression.api import ExpressionPolicyViewSet from authentik.policies.group_membership.api import GroupMembershipPolicyViewSet @@ -97,6 +101,9 @@ router.register("flows/bindings", FlowStageBindingViewSet) router.register("crypto/certificatekeypairs", CertificateKeyPairViewSet) router.register("events/events", EventViewSet) +router.register("events/notifications", NotificationViewSet) +router.register("events/transports", NotificationTransportViewSet) +router.register("events/triggers", NotificationTriggerViewSet) router.register("sources/all", SourceViewSet) router.register("sources/ldap", LDAPSourceViewSet) @@ -107,6 +114,7 @@ router.register("policies/all", PolicyViewSet) router.register("policies/cached", PolicyCacheViewSet, basename="policies_cache") router.register("policies/bindings", PolicyBindingViewSet) router.register("policies/expression", ExpressionPolicyViewSet) +router.register("policies/event_matcher", EventMatcherPolicyViewSet) router.register("policies/group_membership", GroupMembershipPolicyViewSet) router.register("policies/haveibeenpwned", HaveIBeenPwendPolicyViewSet) router.register("policies/password_expiry", PasswordExpiryPolicyViewSet) diff --git a/authentik/core/api/propertymappings.py b/authentik/core/api/propertymappings.py index 3394f5753..9985c52c8 100644 --- a/authentik/core/api/propertymappings.py +++ b/authentik/core/api/propertymappings.py @@ -23,7 +23,7 @@ class PropertyMappingSerializer(ModelSerializer): class PropertyMappingViewSet(ReadOnlyModelViewSet): """PropertyMapping Viewset""" - queryset = PropertyMapping.objects.all() + queryset = PropertyMapping.objects.none() serializer_class = PropertyMappingSerializer def get_queryset(self): diff --git a/authentik/core/api/providers.py b/authentik/core/api/providers.py index 5892b5fa2..08358be49 100644 --- a/authentik/core/api/providers.py +++ b/authentik/core/api/providers.py @@ -39,7 +39,7 @@ class ProviderSerializer(ModelSerializer, MetaNameSerializer): class ProviderViewSet(ModelViewSet): """Provider Viewset""" - queryset = Provider.objects.all() + queryset = Provider.objects.none() serializer_class = ProviderSerializer filterset_fields = { "application": ["isnull"], diff --git a/authentik/core/api/sources.py b/authentik/core/api/sources.py index eda48d324..8e41a7816 100644 --- a/authentik/core/api/sources.py +++ b/authentik/core/api/sources.py @@ -31,7 +31,7 @@ class SourceSerializer(ModelSerializer, MetaNameSerializer): class SourceViewSet(ReadOnlyModelViewSet): """Source Viewset""" - queryset = Source.objects.all() + queryset = Source.objects.none() serializer_class = SourceSerializer lookup_field = "slug" diff --git a/authentik/core/api/users.py b/authentik/core/api/users.py index b79e86b31..e75d451da 100644 --- a/authentik/core/api/users.py +++ b/authentik/core/api/users.py @@ -34,7 +34,7 @@ class UserSerializer(ModelSerializer): class UserViewSet(ModelViewSet): """User Viewset""" - queryset = User.objects.all() + queryset = User.objects.none() serializer_class = UserSerializer def get_queryset(self): diff --git a/authentik/events/api/__init__.py b/authentik/events/api/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/authentik/events/api.py b/authentik/events/api/event.py similarity index 100% rename from authentik/events/api.py rename to authentik/events/api/event.py diff --git a/authentik/events/api/notification.py b/authentik/events/api/notification.py new file mode 100644 index 000000000..39c38dd70 --- /dev/null +++ b/authentik/events/api/notification.py @@ -0,0 +1,33 @@ +"""Notification API Views""" +from rest_framework.serializers import ModelSerializer +from rest_framework.viewsets import ModelViewSet + +from authentik.events.models import Notification + + +class NotificationSerializer(ModelSerializer): + """Notification Serializer""" + + class Meta: + + model = Notification + fields = [ + "pk", + "severity", + "body", + "created", + "event", + "seen", + ] + + +class NotificationViewSet(ModelViewSet): + """Notification Viewset""" + + queryset = Notification.objects.all() + serializer_class = NotificationSerializer + + def get_queryset(self): + if not self.request: + return super().get_queryset() + return Notification.objects.filter(user=self.request.user) diff --git a/authentik/events/api/notification_transport.py b/authentik/events/api/notification_transport.py new file mode 100644 index 000000000..41a5e3bcf --- /dev/null +++ b/authentik/events/api/notification_transport.py @@ -0,0 +1,53 @@ +"""NotificationTransport API Views""" +from django.http.response import Http404 +from guardian.shortcuts import get_objects_for_user +from rest_framework.decorators import action +from rest_framework.request import Request +from rest_framework.response import Response +from rest_framework.serializers import ModelSerializer +from rest_framework.viewsets import ModelViewSet + +from authentik.events.models import ( + Notification, + NotificationSeverity, + NotificationTransport, +) + + +class NotificationTransportSerializer(ModelSerializer): + """NotificationTransport Serializer""" + + class Meta: + + model = NotificationTransport + fields = [ + "pk", + "name", + "mode", + "webhook_url", + ] + + +class NotificationTransportViewSet(ModelViewSet): + """NotificationTransport Viewset""" + + queryset = NotificationTransport.objects.all() + serializer_class = NotificationTransportSerializer + + @action(detail=True, methods=["post"]) + # pylint: disable=invalid-name + def test(self, request: Request, pk=None) -> Response: + """Send example notification using selected transport. Requires + Modify permissions.""" + transports = get_objects_for_user( + request.user, "authentik_events.change_notificationtransport" + ).filter(pk=pk) + if not transports.exists(): + raise Http404 + transport = transports.first() + notification = Notification( + severity=NotificationSeverity.NOTICE, + body=f"Test Notification from transport {transport.name}", + user=request.user, + ) + return Response(transport.send(notification)) diff --git a/authentik/events/api/notification_trigger.py b/authentik/events/api/notification_trigger.py new file mode 100644 index 000000000..eec477a54 --- /dev/null +++ b/authentik/events/api/notification_trigger.py @@ -0,0 +1,26 @@ +"""NotificationTrigger API Views""" +from rest_framework.serializers import ModelSerializer +from rest_framework.viewsets import ModelViewSet + +from authentik.events.models import NotificationTrigger + + +class NotificationTriggerSerializer(ModelSerializer): + """NotificationTrigger Serializer""" + + class Meta: + + model = NotificationTrigger + fields = [ + "pk", + "name", + "transports", + "severity", + ] + + +class NotificationTriggerViewSet(ModelViewSet): + """NotificationTrigger Viewset""" + + queryset = NotificationTrigger.objects.all() + serializer_class = NotificationTriggerSerializer diff --git a/authentik/events/migrations/0010_notification_notificationtransport_notificationtrigger.py b/authentik/events/migrations/0010_notification_notificationtransport_notificationtrigger.py new file mode 100644 index 000000000..395159039 --- /dev/null +++ b/authentik/events/migrations/0010_notification_notificationtransport_notificationtrigger.py @@ -0,0 +1,148 @@ +# Generated by Django 3.1.4 on 2021-01-11 16:36 + +import uuid + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("authentik_policies", "0004_policy_execution_logging"), + ("authentik_core", "0016_auto_20201202_2234"), + ("authentik_events", "0009_auto_20201227_1210"), + ] + + operations = [ + migrations.CreateModel( + name="NotificationTransport", + fields=[ + ( + "uuid", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ("name", models.TextField(unique=True)), + ( + "mode", + models.TextField( + choices=[ + ("webhook", "Generic Webhook"), + ("webhook_slack", "Slack Webhook (Slack/Discord)"), + ("email", "Email"), + ] + ), + ), + ("webhook_url", models.TextField(blank=True)), + ], + options={ + "verbose_name": "Notification Transport", + "verbose_name_plural": "Notification Transports", + }, + ), + migrations.CreateModel( + name="NotificationTrigger", + fields=[ + ( + "policybindingmodel_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="authentik_policies.policybindingmodel", + ), + ), + ("name", models.TextField(unique=True)), + ( + "severity", + models.TextField( + choices=[ + ("notice", "Notice"), + ("warning", "Warning"), + ("alert", "Alert"), + ], + default="notice", + help_text="Controls which severity level the created notifications will have.", + ), + ), + ( + "group", + models.ForeignKey( + blank=True, + help_text="Define which group of users this notification should be sent and shown to. If left empty, Notification won't ben sent.", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="authentik_core.group", + ), + ), + ( + "transports", + models.ManyToManyField( + help_text="Select which transports should be used to notify the user. If none are selected, the notification will only be shown in the authentik UI.", + to="authentik_events.NotificationTransport", + ), + ), + ], + options={ + "verbose_name": "Notification Trigger", + "verbose_name_plural": "Notification Triggers", + }, + bases=("authentik_policies.policybindingmodel",), + ), + migrations.CreateModel( + name="Notification", + fields=[ + ( + "uuid", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ( + "severity", + models.TextField( + choices=[ + ("notice", "Notice"), + ("warning", "Warning"), + ("alert", "Alert"), + ] + ), + ), + ("body", models.TextField()), + ("created", models.DateTimeField(auto_now_add=True)), + ("seen", models.BooleanField(default=False)), + ( + "event", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="authentik_events.event", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "verbose_name": "Notification", + "verbose_name_plural": "Notifications", + }, + ), + ] diff --git a/authentik/events/migrations/0011_notification_trigger_default_v1.py b/authentik/events/migrations/0011_notification_trigger_default_v1.py new file mode 100644 index 000000000..98ca10c93 --- /dev/null +++ b/authentik/events/migrations/0011_notification_trigger_default_v1.py @@ -0,0 +1,108 @@ +# Generated by Django 3.1.4 on 2021-01-10 18:57 + +from django.apps.registry import Apps +from django.db import migrations +from django.db.backends.base.schema import BaseDatabaseSchemaEditor + +from authentik.events.models import EventAction + + +def notify_configuration_error(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): + db_alias = schema_editor.connection.alias + PolicyBinding = apps.get_model("authentik_policies", "PolicyBinding") + EventMatcherPolicy = apps.get_model( + "authentik_policies_event_matcher", "EventMatcherPolicy" + ) + NotificationTrigger = apps.get_model("authentik_events", "NotificationTrigger") + + policy, _ = EventMatcherPolicy.objects.using(db_alias).update_or_create( + name="default-match-configuration-error", + defaults={"action": EventAction.CONFIGURATION_ERROR}, + ) + trigger, _ = NotificationTrigger.objects.using(db_alias).update_or_create( + name="default-notify-configuration-error", + ) + PolicyBinding.objects.using(db_alias).update_or_create( + target=trigger, + policy=policy, + defaults={ + "order": 0, + }, + ) + + +def notify_update(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): + db_alias = schema_editor.connection.alias + PolicyBinding = apps.get_model("authentik_policies", "PolicyBinding") + EventMatcherPolicy = apps.get_model( + "authentik_policies_event_matcher", "EventMatcherPolicy" + ) + NotificationTrigger = apps.get_model("authentik_events", "NotificationTrigger") + + policy, _ = EventMatcherPolicy.objects.using(db_alias).update_or_create( + name="default-match-update", + defaults={"action": EventAction.UPDATE_AVAILABLE}, + ) + trigger, _ = NotificationTrigger.objects.using(db_alias).update_or_create( + name="default-notify-update", + ) + PolicyBinding.objects.using(db_alias).update_or_create( + target=trigger, + policy=policy, + defaults={ + "order": 0, + }, + ) + + +def notify_exception(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): + db_alias = schema_editor.connection.alias + PolicyBinding = apps.get_model("authentik_policies", "PolicyBinding") + EventMatcherPolicy = apps.get_model( + "authentik_policies_event_matcher", "EventMatcherPolicy" + ) + NotificationTrigger = apps.get_model("authentik_events", "NotificationTrigger") + + policy_policy_exc, _ = EventMatcherPolicy.objects.using(db_alias).update_or_create( + name="default-match-policy-exception", + defaults={"action": EventAction.POLICY_EXCEPTION}, + ) + policy_pm_exc, _ = EventMatcherPolicy.objects.using(db_alias).update_or_create( + name="default-match-property-mapping-exception", + defaults={"action": EventAction.PROPERTY_MAPPING_EXCEPTION}, + ) + trigger, _ = NotificationTrigger.objects.using(db_alias).update_or_create( + name="default-notify-exception", + ) + PolicyBinding.objects.using(db_alias).update_or_create( + target=trigger, + policy=policy_policy_exc, + defaults={ + "order": 0, + }, + ) + PolicyBinding.objects.using(db_alias).update_or_create( + target=trigger, + policy=policy_pm_exc, + defaults={ + "order": 1, + }, + ) + + +class Migration(migrations.Migration): + + dependencies = [ + ( + "authentik_events", + "0010_notification_notificationtransport_notificationtrigger", + ), + ("authentik_policies_event_matcher", "0003_auto_20210110_1907"), + ("authentik_policies", "0004_policy_execution_logging"), + ] + + operations = [ + migrations.RunPython(notify_configuration_error), + migrations.RunPython(notify_update), + migrations.RunPython(notify_exception), + ] diff --git a/authentik/events/models.py b/authentik/events/models.py index 193592f47..d64474105 100644 --- a/authentik/events/models.py +++ b/authentik/events/models.py @@ -9,15 +9,20 @@ from django.core.exceptions import ValidationError from django.db import models from django.http import HttpRequest from django.utils.translation import gettext as _ +from requests import post from structlog.stdlib import get_logger +from authentik import __version__ from authentik.core.middleware import ( SESSION_IMPERSONATE_ORIGINAL_USER, SESSION_IMPERSONATE_USER, ) -from authentik.core.models import User +from authentik.core.models import Group, User from authentik.events.utils import cleanse_dict, get_user, sanitize_dict from authentik.lib.utils.http import get_client_ip +from authentik.policies.models import PolicyBindingModel +from authentik.stages.email.tasks import send_mail +from authentik.stages.email.utils import TemplateEmailMessage LOGGER = get_logger("authentik.events") @@ -104,10 +109,12 @@ class Event(models.Model): Events independently from requests. `user` arguments optionally overrides user from requests.""" if hasattr(request, "user"): - self.user = get_user( - request.user, - request.session.get(SESSION_IMPERSONATE_ORIGINAL_USER, None), - ) + original_user = None + if hasattr(request, "session"): + original_user = request.session.get( + SESSION_IMPERSONATE_ORIGINAL_USER, None + ) + self.user = get_user(request.user, original_user) if user: self.user = get_user(user) # Check if we're currently impersonating, and add that user @@ -139,7 +146,189 @@ class Event(models.Model): ) return super().save(*args, **kwargs) + @property + def summary(self) -> str: + """Return a summary of this event.""" + if "message" in self.context: + return self.context["message"] + return f"{self.action}: {self.context}" + + def __str__(self) -> str: + return f"" + class Meta: verbose_name = _("Event") verbose_name_plural = _("Events") + + +class TransportMode(models.TextChoices): + """Modes that a notification transport can send a notification""" + + WEBHOOK = "webhook", _("Generic Webhook") + WEBHOOK_SLACK = "webhook_slack", _("Slack Webhook (Slack/Discord)") + EMAIL = "email", _("Email") + + +class NotificationTransport(models.Model): + """Action which is executed when a Trigger matches""" + + uuid = models.UUIDField(primary_key=True, editable=False, default=uuid4) + + name = models.TextField(unique=True) + mode = models.TextField(choices=TransportMode.choices) + + webhook_url = models.TextField(blank=True) + + def send(self, notification: "Notification") -> list[str]: + """Send notification to user, called from async task""" + if self.mode == TransportMode.WEBHOOK: + return self.send_webhook(notification) + if self.mode == TransportMode.WEBHOOK_SLACK: + return self.send_webhook_slack(notification) + if self.mode == TransportMode.EMAIL: + return self.send_email(notification) + raise ValueError(f"Invalid mode {self.mode} set") + + def send_webhook(self, notification: "Notification") -> list[str]: + """Send notification to generic webhook""" + response = post( + self.webhook_url, + json={ + "body": notification.body, + "severity": notification.severity, + }, + ) + return [ + response.status_code, + response.text, + ] + + def send_webhook_slack(self, notification: "Notification") -> list[str]: + """Send notification to slack or slack-compatible endpoints""" + body = { + "username": "authentik", + "icon_url": "https://goauthentik.io/img/icon.png", + "attachments": [ + { + "author_name": "authentik", + "author_link": "https://goauthentik.io", + "author_icon": "https://goauthentik.io/img/icon.png", + "title": notification.body, + "color": "#fd4b2d", + "fields": [ + { + "title": _("Severity"), + "value": notification.severity, + "short": True, + }, + { + "title": _("Dispatched for user"), + "value": str(notification.user), + "short": True, + }, + ], + "footer": f"authentik v{__version__}", + } + ], + } + if notification.event: + body["attachments"][0]["title"] = notification.event.action + body["attachments"][0]["text"] = notification.event.action + response = post(self.webhook_url, json=body) + return [ + response.status_code, + response.text, + ] + + def send_email(self, notification: "Notification") -> list[str]: + """Send notification via global email configuration""" + body_trunc = ( + (notification.body[:75] + "..") + if len(notification.body) > 75 + else notification.body + ) + mail = TemplateEmailMessage( + subject=f"authentik Notification: {body_trunc}", + template_name="email/setup.html", + to=[notification.user.email], + template_context={ + "body": notification.body, + }, + ) + # Email is sent directly here, as the call to send() should have been from a task. + # pyright: reportGeneralTypeIssues=false + return send_mail(mail.__dict__) # pylint: disable=no-value-for-parameter + + class Meta: + + verbose_name = _("Notification Transport") + verbose_name_plural = _("Notification Transports") + + +class NotificationSeverity(models.TextChoices): + """Severity images that a notification can have""" + + NOTICE = "notice", _("Notice") + WARNING = "warning", _("Warning") + ALERT = "alert", _("Alert") + + +class Notification(models.Model): + """Event Notification""" + + uuid = models.UUIDField(primary_key=True, editable=False, default=uuid4) + severity = models.TextField(choices=NotificationSeverity.choices) + body = models.TextField() + created = models.DateTimeField(auto_now_add=True) + event = models.ForeignKey(Event, on_delete=models.SET_NULL, null=True, blank=True) + seen = models.BooleanField(default=False) + user = models.ForeignKey(User, on_delete=models.CASCADE) + + def __str__(self) -> str: + body_trunc = (self.body[:75] + "..") if len(self.body) > 75 else self.body + return f"Notification for user {self.user}: {body_trunc}" + + class Meta: + + verbose_name = _("Notification") + verbose_name_plural = _("Notifications") + + +class NotificationTrigger(PolicyBindingModel): + """Decide when to create a Notification based on policies attached to this object.""" + + name = models.TextField(unique=True) + transports = models.ManyToManyField( + NotificationTransport, + help_text=_( + ( + "Select which transports should be used to notify the user. If none are " + "selected, the notification will only be shown in the authentik UI." + ) + ), + ) + severity = models.TextField( + choices=NotificationSeverity.choices, + default=NotificationSeverity.NOTICE, + help_text=_( + "Controls which severity level the created notifications will have." + ), + ) + group = models.ForeignKey( + Group, + help_text=_( + ( + "Define which group of users this notification should be sent and shown to. " + "If left empty, Notification won't ben sent." + ) + ), + null=True, + blank=True, + on_delete=models.SET_NULL, + ) + + class Meta: + + verbose_name = _("Notification Trigger") + verbose_name_plural = _("Notification Triggers") diff --git a/authentik/events/signals.py b/authentik/events/signals.py index 03ed367f9..01b78dd90 100644 --- a/authentik/events/signals.py +++ b/authentik/events/signals.py @@ -7,12 +7,14 @@ from django.contrib.auth.signals import ( user_logged_out, user_login_failed, ) +from django.db.models.signals import post_save from django.dispatch import receiver from django.http import HttpRequest from authentik.core.models import User from authentik.core.signals import password_changed from authentik.events.models import Event, EventAction +from authentik.events.tasks import event_notification_handler from authentik.stages.invitation.models import Invitation from authentik.stages.invitation.signals import invitation_used from authentik.stages.user_write.signals import user_write @@ -95,3 +97,10 @@ def on_password_changed(sender, user: User, password: str, **_): """Log password change""" thread = EventNewThread(EventAction.PASSWORD_SET, None, user=user) thread.run() + + +@receiver(post_save, sender=Event) +# pylint: disable=unused-argument +def event_post_save_notification(sender, instance: Event, **_): + """Start task to check if any policies trigger an notification on this event""" + event_notification_handler.delay(instance.event_uuid.hex) diff --git a/authentik/events/tasks.py b/authentik/events/tasks.py new file mode 100644 index 000000000..ecd5c6926 --- /dev/null +++ b/authentik/events/tasks.py @@ -0,0 +1,80 @@ +"""Event notification tasks""" +from guardian.shortcuts import get_anonymous_user +from structlog import get_logger + +from authentik.events.models import ( + Event, + Notification, + NotificationTransport, + NotificationTrigger, +) +from authentik.lib.tasks import MonitoredTask, TaskResult, TaskResultStatus +from authentik.policies.engine import PolicyEngine +from authentik.root.celery import CELERY_APP + +LOGGER = get_logger() + + +@CELERY_APP.task() +def event_notification_handler(event_uuid: str): + """Start task for each trigger definition""" + for trigger in NotificationTrigger.objects.all(): + event_trigger_handler.apply_async( + args=[event_uuid, trigger.name], queue="authentik_events" + ) + + +@CELERY_APP.task() +def event_trigger_handler(event_uuid: str, trigger_name: str): + """Check if policies attached to NotificationTrigger match event""" + event: Event = Event.objects.get(event_uuid=event_uuid) + trigger: NotificationTrigger = NotificationTrigger.objects.get(name=trigger_name) + + if "policy_uuid" in event.context: + policy_uuid = event.context["policy_uuid"] + if trigger.policies.filter(policy_uuid=policy_uuid).exists(): + # Event has been created by a policy that is attached + # to this trigger. To prevent infinite loops, we stop here + LOGGER.debug("e(trigger): attempting to prevent infinite loop") + return + + if not trigger.group: + LOGGER.debug("e(trigger): trigger has no group") + return + + policy_engine = PolicyEngine(trigger, get_anonymous_user()) + policy_engine.request.context["event"] = event + policy_engine.build() + result = policy_engine.result + if not result.passing: + return + + LOGGER.debug("e(trigger): event trigger matched") + # Create the notification objects + for user in trigger.group.users.all(): + notification = Notification.objects.create( + severity=trigger.severity, body=event.summary, event=event, user=user + ) + + for transport in trigger.transports.all(): + notification_transport.apply_async( + args=[notification.pk, transport.pk], queue="authentik_events" + ) + + +@CELERY_APP.task(bind=True, base=MonitoredTask) +def notification_transport( + self: MonitoredTask, notification_pk: int, transport_pk: int +): + """Send notification over specified transport""" + self.save_on_success = False + try: + notification: Notification = Notification.objects.get(pk=notification_pk) + transport: NotificationTransport = NotificationTransport.objects.get( + pk=transport_pk + ) + transport.send(notification) + self.set_status(TaskResult(TaskResultStatus.SUCCESSFUL)) + except Exception as exc: + self.set_status(TaskResult(TaskResultStatus.ERROR).with_error(exc)) + raise exc diff --git a/authentik/events/tests/test_api.py b/authentik/events/tests/test_api.py new file mode 100644 index 000000000..f13d86116 --- /dev/null +++ b/authentik/events/tests/test_api.py @@ -0,0 +1,24 @@ +"""Event API tests""" + +from django.shortcuts import reverse +from rest_framework.test import APITestCase + +from authentik.core.models import User +from authentik.events.models import Event, EventAction + + +class TestEventsAPI(APITestCase): + """Test Event API""" + + def test_top_n(self): + """Test top_per_user""" + user = User.objects.get(username="akadmin") + self.client.force_login(user) + + event = Event.new(EventAction.AUTHORIZE_APPLICATION) + event.save() # We save to ensure nothing is un-saveable + response = self.client.get( + reverse("authentik_api:event-top-per-user"), + data={"filter_action": EventAction.AUTHORIZE_APPLICATION}, + ) + self.assertEqual(response.status_code, 200) diff --git a/authentik/events/tests/test_event.py b/authentik/events/tests/test_event.py index e4d2d4887..9389ad144 100644 --- a/authentik/events/tests/test_event.py +++ b/authentik/events/tests/test_event.py @@ -1,9 +1,10 @@ -"""events event tests""" +"""event tests""" from django.contrib.contenttypes.models import ContentType from django.test import TestCase from guardian.shortcuts import get_anonymous_user +from authentik.core.models import Group from authentik.events.models import Event from authentik.policies.dummy.models import DummyPolicy @@ -13,14 +14,24 @@ class TestEvents(TestCase): def test_new_with_model(self): """Create a new Event passing a model as kwarg""" - event = Event.new("unittest", test={"model": get_anonymous_user()}) + test_model = Group.objects.create(name="test") + event = Event.new("unittest", test={"model": test_model}) event.save() # We save to ensure nothing is un-saveable - model_content_type = ContentType.objects.get_for_model(get_anonymous_user()) + model_content_type = ContentType.objects.get_for_model(test_model) self.assertEqual( event.context.get("test").get("model").get("app"), model_content_type.app_label, ) + def test_new_with_user(self): + """Create a new Event passing a user as kwarg""" + event = Event.new("unittest", test={"model": get_anonymous_user()}) + event.save() # We save to ensure nothing is un-saveable + self.assertEqual( + event.context.get("test").get("model").get("username"), + get_anonymous_user().username, + ) + def test_new_with_uuid_model(self): """Create a new Event passing a model (with UUID PK) as kwarg""" temp_model = DummyPolicy.objects.create(name="test", result=True) diff --git a/authentik/events/tests/test_middleware.py b/authentik/events/tests/test_middleware.py new file mode 100644 index 000000000..f30c93e51 --- /dev/null +++ b/authentik/events/tests/test_middleware.py @@ -0,0 +1,48 @@ +"""Event Middleware tests""" + +from django.shortcuts import reverse +from rest_framework.test import APITestCase + +from authentik.core.models import Application, User +from authentik.events.models import Event, EventAction + + +class TestEventsMiddleware(APITestCase): + """Test Event Middleware""" + + def setUp(self) -> None: + super().setUp() + self.user = User.objects.get(username="akadmin") + self.client.force_login(self.user) + + def test_create(self): + """Test model creation event""" + self.client.post( + reverse("authentik_api:application-list"), + data={"name": "test-create", "slug": "test-create"}, + ) + self.assertTrue(Application.objects.filter(name="test-create").exists()) + self.assertTrue( + Event.objects.filter( + action=EventAction.MODEL_CREATED, + context__model__model_name="application", + context__model__app="authentik_core", + context__model__name="test-create", + ).exists() + ) + + def test_delete(self): + """Test model creation event""" + Application.objects.create(name="test-delete", slug="test-delete") + self.client.delete( + reverse("authentik_api:application-detail", kwargs={"slug": "test-delete"}) + ) + self.assertFalse(Application.objects.filter(name="test").exists()) + self.assertTrue( + Event.objects.filter( + action=EventAction.MODEL_DELETED, + context__model__model_name="application", + context__model__app="authentik_core", + context__model__name="test-delete", + ).exists() + ) diff --git a/authentik/events/tests/test_notifications.py b/authentik/events/tests/test_notifications.py new file mode 100644 index 000000000..2dbc93f6a --- /dev/null +++ b/authentik/events/tests/test_notifications.py @@ -0,0 +1,77 @@ +"""Notification tests""" + +from unittest.mock import MagicMock, patch + +from django.test import TestCase + +from authentik.core.models import Group, User +from authentik.events.models import ( + Event, + EventAction, + NotificationTransport, + NotificationTrigger, +) +from authentik.policies.event_matcher.models import EventMatcherPolicy +from authentik.policies.exceptions import PolicyException +from authentik.policies.models import PolicyBinding + + +class TestEventsNotifications(TestCase): + """Test Event Notifications""" + + def setUp(self) -> None: + self.group = Group.objects.create(name="test-group") + self.user = User.objects.create(name="test-user") + self.group.users.add(self.user) + self.group.save() + + def test_trigger_single(self): + """Test simple transport triggering""" + transport = NotificationTransport.objects.create(name="transport") + trigger = NotificationTrigger.objects.create(name="trigger", group=self.group) + 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).save() + self.assertEqual(execute_mock.call_count, 1) + + def test_trigger_no_group(self): + """Test trigger without group""" + trigger = NotificationTrigger.objects.create(name="trigger") + 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).save() + self.assertEqual(execute_mock.call_count, 0) + + def test_policy_error_recursive(self): + """Test Policy error which would cause recursion""" + transport = NotificationTransport.objects.create(name="transport") + trigger = NotificationTrigger.objects.create(name="trigger", group=self.group) + 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() + passes = MagicMock(side_effect=PolicyException) + with patch( + "authentik.policies.event_matcher.models.EventMatcherPolicy.passes", passes + ): + with patch( + "authentik.events.models.NotificationTransport.send", execute_mock + ): + Event.new(EventAction.CUSTOM_PREFIX).save() + self.assertEqual(passes.call_count, 0) diff --git a/authentik/events/utils.py b/authentik/events/utils.py index 080acf3c1..3305303b8 100644 --- a/authentik/events/utils.py +++ b/authentik/events/utils.py @@ -5,8 +5,10 @@ from typing import Any, Dict, Optional from uuid import UUID from django.contrib.auth.models import AnonymousUser +from django.core.handlers.wsgi import WSGIRequest from django.db import models from django.db.models.base import Model +from django.http.request import HttpRequest from django.views.debug import SafeExceptionReporterFilter from guardian.utils import get_anonymous_user @@ -83,10 +85,14 @@ def sanitize_dict(source: Dict[Any, Any]) -> Dict[Any, Any]: value = asdict(value) if isinstance(value, dict): final_dict[key] = sanitize_dict(value) + elif isinstance(value, User): + final_dict[key] = sanitize_dict(get_user(value)) elif isinstance(value, models.Model): final_dict[key] = sanitize_dict(model_to_dict(value)) elif isinstance(value, UUID): final_dict[key] = value.hex + elif isinstance(value, (HttpRequest, WSGIRequest)): + continue else: final_dict[key] = value return final_dict diff --git a/authentik/policies/event_matcher/__init__.py b/authentik/policies/event_matcher/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/authentik/policies/event_matcher/api.py b/authentik/policies/event_matcher/api.py new file mode 100644 index 000000000..ffb5e22c8 --- /dev/null +++ b/authentik/policies/event_matcher/api.py @@ -0,0 +1,25 @@ +"""Event Matcher Policy API""" +from rest_framework.serializers import ModelSerializer +from rest_framework.viewsets import ModelViewSet + +from authentik.policies.event_matcher.models import EventMatcherPolicy +from authentik.policies.forms import GENERAL_SERIALIZER_FIELDS + + +class EventMatcherPolicySerializer(ModelSerializer): + """Event Matcher Policy Serializer""" + + class Meta: + model = EventMatcherPolicy + fields = GENERAL_SERIALIZER_FIELDS + [ + "action", + "client_ip", + "app", + ] + + +class EventMatcherPolicyViewSet(ModelViewSet): + """Event Matcher Policy Viewset""" + + queryset = EventMatcherPolicy.objects.all() + serializer_class = EventMatcherPolicySerializer diff --git a/authentik/policies/event_matcher/apps.py b/authentik/policies/event_matcher/apps.py new file mode 100644 index 000000000..00a94c3a3 --- /dev/null +++ b/authentik/policies/event_matcher/apps.py @@ -0,0 +1,11 @@ +"""authentik Event Matcher policy app config""" + +from django.apps import AppConfig + + +class AuthentikPoliciesEventMatcherConfig(AppConfig): + """authentik Event Matcher policy app config""" + + name = "authentik.policies.event_matcher" + label = "authentik_policies_event_matcher" + verbose_name = "authentik Policies.Event Matcher" diff --git a/authentik/policies/event_matcher/forms.py b/authentik/policies/event_matcher/forms.py new file mode 100644 index 000000000..8f4fc55f4 --- /dev/null +++ b/authentik/policies/event_matcher/forms.py @@ -0,0 +1,23 @@ +"""authentik Event Matcher Policy forms""" + +from django import forms + +from authentik.policies.event_matcher.models import EventMatcherPolicy +from authentik.policies.forms import GENERAL_FIELDS + + +class EventMatcherPolicyForm(forms.ModelForm): + """EventMatcherPolicy Form""" + + class Meta: + + model = EventMatcherPolicy + fields = GENERAL_FIELDS + [ + "action", + "client_ip", + "app", + ] + widgets = { + "name": forms.TextInput(), + "client_ip": forms.TextInput(), + } diff --git a/authentik/policies/event_matcher/migrations/0001_initial.py b/authentik/policies/event_matcher/migrations/0001_initial.py new file mode 100644 index 000000000..f96f82976 --- /dev/null +++ b/authentik/policies/event_matcher/migrations/0001_initial.py @@ -0,0 +1,70 @@ +# Generated by Django 3.1.4 on 2020-12-24 10:32 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("authentik_policies", "0004_policy_execution_logging"), + ] + + operations = [ + migrations.CreateModel( + name="EventMatcherPolicy", + fields=[ + ( + "policy_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="authentik_policies.policy", + ), + ), + ( + "action", + models.TextField( + blank=True, + choices=[ + ("login", "Login"), + ("login_failed", "Login Failed"), + ("logout", "Logout"), + ("user_write", "User Write"), + ("suspicious_request", "Suspicious Request"), + ("password_set", "Password Set"), + ("token_view", "Token View"), + ("invitation_created", "Invite Created"), + ("invitation_used", "Invite Used"), + ("authorize_application", "Authorize Application"), + ("source_linked", "Source Linked"), + ("impersonation_started", "Impersonation Started"), + ("impersonation_ended", "Impersonation Ended"), + ("policy_execution", "Policy Execution"), + ("policy_exception", "Policy Exception"), + ( + "property_mapping_exception", + "Property Mapping Exception", + ), + ("model_created", "Model Created"), + ("model_updated", "Model Updated"), + ("model_deleted", "Model Deleted"), + ("update_available", "Update Available"), + ("custom_", "Custom Prefix"), + ], + ), + ), + ("client_ip", models.TextField(blank=True)), + ], + options={ + "verbose_name": "Group Membership Policy", + "verbose_name_plural": "Group Membership Policies", + }, + bases=("authentik_policies.policy",), + ), + ] diff --git a/authentik/policies/event_matcher/migrations/0002_auto_20201230_2046.py b/authentik/policies/event_matcher/migrations/0002_auto_20201230_2046.py new file mode 100644 index 000000000..9f65e5299 --- /dev/null +++ b/authentik/policies/event_matcher/migrations/0002_auto_20201230_2046.py @@ -0,0 +1,43 @@ +# Generated by Django 3.1.4 on 2020-12-30 20:46 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_policies_event_matcher", "0001_initial"), + ] + + operations = [ + migrations.AlterField( + model_name="eventmatcherpolicy", + name="action", + field=models.TextField( + blank=True, + choices=[ + ("login", "Login"), + ("login_failed", "Login Failed"), + ("logout", "Logout"), + ("user_write", "User Write"), + ("suspicious_request", "Suspicious Request"), + ("password_set", "Password Set"), + ("token_view", "Token View"), + ("invitation_used", "Invite Used"), + ("authorize_application", "Authorize Application"), + ("source_linked", "Source Linked"), + ("impersonation_started", "Impersonation Started"), + ("impersonation_ended", "Impersonation Ended"), + ("policy_execution", "Policy Execution"), + ("policy_exception", "Policy Exception"), + ("property_mapping_exception", "Property Mapping Exception"), + ("configuration_error", "Configuration Error"), + ("model_created", "Model Created"), + ("model_updated", "Model Updated"), + ("model_deleted", "Model Deleted"), + ("update_available", "Update Available"), + ("custom_", "Custom Prefix"), + ], + ), + ), + ] diff --git a/authentik/policies/event_matcher/migrations/0003_auto_20210110_1907.py b/authentik/policies/event_matcher/migrations/0003_auto_20210110_1907.py new file mode 100644 index 000000000..6444b413a --- /dev/null +++ b/authentik/policies/event_matcher/migrations/0003_auto_20210110_1907.py @@ -0,0 +1,111 @@ +# Generated by Django 3.1.4 on 2021-01-10 19:07 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_policies_event_matcher", "0002_auto_20201230_2046"), + ] + + operations = [ + migrations.AddField( + model_name="eventmatcherpolicy", + name="app", + field=models.TextField( + blank=True, + choices=[ + ("authentik.admin", "authentik Admin"), + ("authentik.api", "authentik API"), + ("authentik.events", "authentik Events"), + ("authentik.crypto", "authentik Crypto"), + ("authentik.flows", "authentik Flows"), + ("authentik.outposts", "authentik Outpost"), + ("authentik.lib", "authentik lib"), + ("authentik.policies", "authentik Policies"), + ("authentik.policies.dummy", "authentik Policies.Dummy"), + ( + "authentik.policies.event_matcher", + "authentik Policies.Event Matcher", + ), + ("authentik.policies.expiry", "authentik Policies.Expiry"), + ("authentik.policies.expression", "authentik Policies.Expression"), + ( + "authentik.policies.group_membership", + "authentik Policies.Group Membership", + ), + ("authentik.policies.hibp", "authentik Policies.HaveIBeenPwned"), + ("authentik.policies.password", "authentik Policies.Password"), + ("authentik.policies.reputation", "authentik Policies.Reputation"), + ("authentik.providers.proxy", "authentik Providers.Proxy"), + ("authentik.providers.oauth2", "authentik Providers.OAuth2"), + ("authentik.providers.saml", "authentik Providers.SAML"), + ("authentik.recovery", "authentik Recovery"), + ("authentik.sources.ldap", "authentik Sources.LDAP"), + ("authentik.sources.oauth", "authentik Sources.OAuth"), + ("authentik.sources.saml", "authentik Sources.SAML"), + ("authentik.stages.captcha", "authentik Stages.Captcha"), + ("authentik.stages.consent", "authentik Stages.Consent"), + ("authentik.stages.dummy", "authentik Stages.Dummy"), + ("authentik.stages.email", "authentik Stages.Email"), + ("authentik.stages.prompt", "authentik Stages.Prompt"), + ( + "authentik.stages.identification", + "authentik Stages.Identification", + ), + ("authentik.stages.invitation", "authentik Stages.User Invitation"), + ("authentik.stages.user_delete", "authentik Stages.User Delete"), + ("authentik.stages.user_login", "authentik Stages.User Login"), + ("authentik.stages.user_logout", "authentik Stages.User Logout"), + ("authentik.stages.user_write", "authentik Stages.User Write"), + ("authentik.stages.otp_static", "authentik OTP.Static"), + ("authentik.stages.otp_time", "authentik OTP.Time"), + ("authentik.stages.otp_validate", "authentik OTP.Validate"), + ("authentik.stages.password", "authentik Stages.Password"), + ("authentik.core", "authentik Core"), + ], + default="", + help_text="Match events created by selected application. When left empty, all applications are matched.", + ), + ), + migrations.AlterField( + model_name="eventmatcherpolicy", + name="action", + field=models.TextField( + blank=True, + choices=[ + ("login", "Login"), + ("login_failed", "Login Failed"), + ("logout", "Logout"), + ("user_write", "User Write"), + ("suspicious_request", "Suspicious Request"), + ("password_set", "Password Set"), + ("token_view", "Token View"), + ("invitation_used", "Invite Used"), + ("authorize_application", "Authorize Application"), + ("source_linked", "Source Linked"), + ("impersonation_started", "Impersonation Started"), + ("impersonation_ended", "Impersonation Ended"), + ("policy_execution", "Policy Execution"), + ("policy_exception", "Policy Exception"), + ("property_mapping_exception", "Property Mapping Exception"), + ("configuration_error", "Configuration Error"), + ("model_created", "Model Created"), + ("model_updated", "Model Updated"), + ("model_deleted", "Model Deleted"), + ("update_available", "Update Available"), + ("custom_", "Custom Prefix"), + ], + help_text="Match created events with this action type. When left empty, all action types will be matched.", + ), + ), + migrations.AlterField( + model_name="eventmatcherpolicy", + name="client_ip", + field=models.TextField( + blank=True, + help_text="Matches Event's Client IP (strict matching, for network matching use an Expression Policy)", + ), + ), + ] diff --git a/authentik/policies/event_matcher/migrations/__init__.py b/authentik/policies/event_matcher/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/authentik/policies/event_matcher/models.py b/authentik/policies/event_matcher/models.py new file mode 100644 index 000000000..f896a9460 --- /dev/null +++ b/authentik/policies/event_matcher/models.py @@ -0,0 +1,91 @@ +"""Event Matcher models""" +from typing import Type + +from django.apps import apps +from django.db import models +from django.forms import ModelForm +from django.utils.translation import gettext as _ +from rest_framework.serializers import BaseSerializer + +from authentik.events.models import Event, EventAction +from authentik.policies.models import Policy +from authentik.policies.types import PolicyRequest, PolicyResult + + +def app_choices() -> list[tuple[str, str]]: + """Get a list of all installed applications that create events. + Returns a list of tuples containing (dotted.app.path, name)""" + choices = [] + for app in apps.get_app_configs(): + if app.label.startswith("authentik"): + choices.append((app.name, app.verbose_name)) + return choices + + +class EventMatcherPolicy(Policy): + """Passes when Event matches selected criteria.""" + + action = models.TextField( + choices=EventAction.choices, + blank=True, + help_text=_( + ( + "Match created events with this action type. " + "When left empty, all action types will be matched." + ) + ), + ) + app = models.TextField( + choices=app_choices(), + blank=True, + default="", + help_text=_( + ( + "Match events created by selected application. " + "When left empty, all applications are matched." + ) + ), + ) + client_ip = models.TextField( + blank=True, + help_text=_( + ( + "Matches Event's Client IP (strict matching, " + "for network matching use an Expression Policy)" + ) + ), + ) + + @property + def serializer(self) -> BaseSerializer: + from authentik.policies.event_matcher.api import ( + EventMatcherPolicySerializer, + ) + + return EventMatcherPolicySerializer + + @property + def form(self) -> Type[ModelForm]: + from authentik.policies.event_matcher.forms import EventMatcherPolicyForm + + return EventMatcherPolicyForm + + def passes(self, request: PolicyRequest) -> PolicyResult: + if "event" not in request.context: + return PolicyResult(False) + event: Event = request.context["event"] + if self.action != "": + if event.action != self.action: + return PolicyResult(False, "Action did not match.") + if self.client_ip != "": + if event.client_ip != self.client_ip: + return PolicyResult(False, "Client IP did not match.") + if self.app != "": + if event.app != self.app: + return PolicyResult(False, "App did not match.") + return PolicyResult(True) + + class Meta: + + verbose_name = _("Group Membership Policy") + verbose_name_plural = _("Group Membership Policies") diff --git a/authentik/policies/event_matcher/tests.py b/authentik/policies/event_matcher/tests.py new file mode 100644 index 000000000..504285d35 --- /dev/null +++ b/authentik/policies/event_matcher/tests.py @@ -0,0 +1,68 @@ +"""event_matcher tests""" +from django.test import TestCase +from guardian.shortcuts import get_anonymous_user + +from authentik.events.models import Event, EventAction +from authentik.policies.event_matcher.models import EventMatcherPolicy +from authentik.policies.types import PolicyRequest + + +class TestEventMatcherPolicy(TestCase): + """EventMatcherPolicy tests""" + + def test_drop_action(self): + """Test drop event""" + event = Event.new(EventAction.LOGIN) + request = PolicyRequest(get_anonymous_user()) + request.context["event"] = event + policy: EventMatcherPolicy = EventMatcherPolicy.objects.create( + action=EventAction.LOGIN_FAILED + ) + response = policy.passes(request) + self.assertFalse(response.passing) + self.assertTupleEqual(response.messages, ("Action did not match.",)) + + def test_drop_client_ip(self): + """Test drop event""" + event = Event.new(EventAction.LOGIN) + event.client_ip = "1.2.3.4" + request = PolicyRequest(get_anonymous_user()) + request.context["event"] = event + policy: EventMatcherPolicy = EventMatcherPolicy.objects.create( + client_ip="1.2.3.5" + ) + response = policy.passes(request) + self.assertFalse(response.passing) + self.assertTupleEqual(response.messages, ("Client IP did not match.",)) + + def test_drop_app(self): + """Test drop event""" + event = Event.new(EventAction.LOGIN) + event.app = "foo" + request = PolicyRequest(get_anonymous_user()) + request.context["event"] = event + policy: EventMatcherPolicy = EventMatcherPolicy.objects.create(app="bar") + response = policy.passes(request) + self.assertFalse(response.passing) + self.assertTupleEqual(response.messages, ("App did not match.",)) + + def test_passing(self): + """Test passing event""" + event = Event.new(EventAction.LOGIN) + event.client_ip = "1.2.3.4" + request = PolicyRequest(get_anonymous_user()) + request.context["event"] = event + policy: EventMatcherPolicy = EventMatcherPolicy.objects.create( + client_ip="1.2.3.4" + ) + response = policy.passes(request) + self.assertTrue(response.passing) + + def test_invalid(self): + """Test passing event""" + request = PolicyRequest(get_anonymous_user()) + policy: EventMatcherPolicy = EventMatcherPolicy.objects.create( + client_ip="1.2.3.4" + ) + response = policy.passes(request) + self.assertFalse(response.passing) diff --git a/authentik/policies/expression/evaluator.py b/authentik/policies/expression/evaluator.py index 3c5fb8190..236c98c20 100644 --- a/authentik/policies/expression/evaluator.py +++ b/authentik/policies/expression/evaluator.py @@ -1,16 +1,14 @@ """authentik expression policy evaluator""" from ipaddress import ip_address, ip_network -from traceback import format_tb from typing import TYPE_CHECKING, List, Optional from django.http import HttpRequest from structlog.stdlib import get_logger -from authentik.events.models import Event, EventAction -from authentik.events.utils import model_to_dict, sanitize_dict from authentik.flows.planner import PLAN_CONTEXT_SSO from authentik.lib.expression.evaluator import BaseEvaluator from authentik.lib.utils.http import get_client_ip +from authentik.policies.exceptions import PolicyException from authentik.policies.types import PolicyRequest, PolicyResult LOGGER = get_logger() @@ -57,32 +55,22 @@ class PolicyEvaluator(BaseEvaluator): def handle_error(self, exc: Exception, expression_source: str): """Exception Handler""" - error_string = "\n".join(format_tb(exc.__traceback__) + [str(exc)]) - event = Event.new( - EventAction.POLICY_EXCEPTION, - expression=expression_source, - error=error_string, - request=self._context["request"], - ) - if self.policy: - event.context["model"] = sanitize_dict(model_to_dict(self.policy)) - if "http_request" in self._context: - event.from_http(self._context["http_request"]) - else: - event.set_user(self._context["request"].user) - event.save() + raise PolicyException(str(exc)) from exc def evaluate(self, expression_source: str) -> PolicyResult: """Parse and evaluate expression. Policy is expected to return a truthy object. Messages can be added using 'do ak_message()'.""" try: result = super().evaluate(expression_source) + except PolicyException as exc: + # PolicyExceptions should be propagated back to the process, + # which handles recording and returning a correct result + raise exc except Exception as exc: # pylint: disable=broad-except LOGGER.warning("Expression error", exc=exc) return PolicyResult(False, str(exc)) else: - policy_result = PolicyResult(False) - policy_result.messages = tuple(self._messages) + policy_result = PolicyResult(False, *self._messages) if result is None: LOGGER.warning( "Expression policy returned None", diff --git a/authentik/policies/expression/tests.py b/authentik/policies/expression/tests.py index 4d809b159..ceb80bd3a 100644 --- a/authentik/policies/expression/tests.py +++ b/authentik/policies/expression/tests.py @@ -3,7 +3,7 @@ from django.core.exceptions import ValidationError from django.test import TestCase from guardian.shortcuts import get_anonymous_user -from authentik.events.models import Event, EventAction +from authentik.policies.exceptions import PolicyException from authentik.policies.expression.evaluator import PolicyEvaluator from authentik.policies.expression.models import ExpressionPolicy from authentik.policies.types import PolicyRequest @@ -44,30 +44,8 @@ class TestEvaluator(TestCase): template = ";" evaluator = PolicyEvaluator("test") evaluator.set_policy_request(self.request) - result = evaluator.evaluate(template) - self.assertEqual(result.passing, False) - self.assertEqual(result.messages, ("invalid syntax (test, line 3)",)) - self.assertTrue( - Event.objects.filter( - action=EventAction.POLICY_EXCEPTION, - context__expression=template, - ).exists() - ) - - def test_undefined(self): - """test undefined result""" - template = "{{ foo.bar }}" - evaluator = PolicyEvaluator("test") - evaluator.set_policy_request(self.request) - result = evaluator.evaluate(template) - self.assertEqual(result.passing, False) - self.assertEqual(result.messages, ("name 'foo' is not defined",)) - self.assertTrue( - Event.objects.filter( - action=EventAction.POLICY_EXCEPTION, - context__expression=template, - ).exists() - ) + with self.assertRaises(PolicyException): + evaluator.evaluate(template) def test_validate(self): """test validate""" diff --git a/authentik/policies/process.py b/authentik/policies/process.py index 649e3e48a..4ac054d84 100644 --- a/authentik/policies/process.py +++ b/authentik/policies/process.py @@ -1,6 +1,7 @@ """authentik policy task""" from multiprocessing import Process from multiprocessing.connection import Connection +from traceback import format_tb from typing import Optional from django.core.cache import cache @@ -19,7 +20,7 @@ LOGGER = get_logger() def cache_key(binding: PolicyBinding, request: PolicyRequest) -> str: """Generate Cache key for policy""" prefix = f"policy_{binding.policy_binding_uuid.hex}_{binding.policy.pk.hex}" - if request.http_request: + if request.http_request and hasattr(request.http_request, "session"): prefix += f"_{request.http_request.session.session_key}" if request.user: prefix += f"#{request.user.pk}" @@ -47,6 +48,23 @@ class PolicyProcess(Process): if connection: self.connection = connection + def create_event(self, action: str, **kwargs): + """Create event with common values from `self.request` and `self.binding`.""" + # Keep a reference to http_request even if its None, because cleanse_dict will remove it + http_request = self.request.http_request + event = Event.new( + action=action, + policy_uuid=self.binding.policy.policy_uuid.hex, + binding=self.binding, + request=self.request, + **kwargs, + ) + event.set_user(self.request.user) + if http_request: + event.from_http(http_request) + else: + event.save() + def execute(self) -> PolicyResult: """Run actual policy, returns result""" LOGGER.debug( @@ -58,15 +76,11 @@ class PolicyProcess(Process): try: policy_result = self.binding.policy.passes(self.request) if self.binding.policy.execution_logging: - event = Event.new( - EventAction.POLICY_EXECUTION, - request=self.request, - binding=self.binding, - result=policy_result, - ) - event.set_user(self.request.user) - event.save() + self.create_event(EventAction.POLICY_EXECUTION, result=policy_result) except PolicyException as exc: + # Create policy exception event + error_string = "\n".join(format_tb(exc.__traceback__) + [str(exc)]) + self.create_event(EventAction.POLICY_EXCEPTION, error=error_string) LOGGER.debug("P_ENG(proc): error", exc=exc) policy_result = PolicyResult(False, str(exc)) policy_result.source_policy = self.binding.policy diff --git a/authentik/policies/tests/test_process.py b/authentik/policies/tests/test_process.py index af46e757b..3dfac26ba 100644 --- a/authentik/policies/tests/test_process.py +++ b/authentik/policies/tests/test_process.py @@ -1,6 +1,6 @@ """policy process tests""" from django.core.cache import cache -from django.test import TestCase +from django.test import RequestFactory, TestCase from authentik.core.models import Application, User from authentik.events.models import Event, EventAction @@ -22,6 +22,7 @@ class TestPolicyProcess(TestCase): def setUp(self): clear_policy_cache() + self.factory = RequestFactory() self.user = User.objects.create_user(username="policyuser") def test_invalid(self): @@ -64,7 +65,9 @@ class TestPolicyProcess(TestCase): def test_exception(self): """Test policy execution""" policy = Policy.objects.create() - binding = PolicyBinding(policy=policy) + binding = PolicyBinding( + policy=policy, target=Application.objects.create(name="test") + ) request = PolicyRequest(self.user) response = PolicyProcess(binding, request, None).execute() @@ -79,29 +82,47 @@ class TestPolicyProcess(TestCase): policy=policy, target=Application.objects.create(name="test") ) + http_request = self.factory.get("/") + http_request.user = self.user + request = PolicyRequest(self.user) + request.http_request = http_request response = PolicyProcess(binding, request, None).execute() self.assertEqual(response.passing, False) self.assertEqual(response.messages, ("dummy",)) events = Event.objects.filter( action=EventAction.POLICY_EXECUTION, + context__policy_uuid=policy.policy_uuid.hex, ) self.assertTrue(events.exists()) self.assertEqual(len(events), 1) event = events.first() + self.assertEqual(event.user["username"], self.user.username) self.assertEqual(event.context["result"]["passing"], False) self.assertEqual(event.context["result"]["messages"], ["dummy"]) + self.assertEqual(event.client_ip, "127.0.0.1") def test_raises(self): """Test policy that raises error""" policy_raises = ExpressionPolicy.objects.create( name="raises", expression="{{ 0/0 }}" ) - binding = PolicyBinding(policy=policy_raises) + binding = PolicyBinding( + policy=policy_raises, target=Application.objects.create(name="test") + ) request = PolicyRequest(self.user) response = PolicyProcess(binding, request, None).execute() self.assertEqual(response.passing, False) self.assertEqual(response.messages, ("division by zero",)) - # self.assert + + events = Event.objects.filter( + action=EventAction.POLICY_EXCEPTION, + context__policy_uuid=policy_raises.policy_uuid.hex, + ) + self.assertTrue(events.exists()) + self.assertEqual(len(events), 1) + event = events.first() + self.assertEqual(event.user["username"], self.user.username) + self.assertIn("division by zero", event.context["error"]) diff --git a/authentik/policies/types.py b/authentik/policies/types.py index c9ba8522c..df2ad1951 100644 --- a/authentik/policies/types.py +++ b/authentik/policies/types.py @@ -2,7 +2,7 @@ from __future__ import annotations from dataclasses import dataclass -from typing import TYPE_CHECKING, Dict, List, Optional, Tuple +from typing import TYPE_CHECKING, Any, Optional from django.db.models import Model from django.http import HttpRequest @@ -19,7 +19,7 @@ class PolicyRequest: user: User http_request: Optional[HttpRequest] obj: Optional[Model] - context: Dict[str, str] + context: dict[str, Any] def __init__(self, user: User): super().__init__() @@ -37,10 +37,10 @@ class PolicyResult: """Small data-class to hold policy results""" passing: bool - messages: Tuple[str, ...] + messages: tuple[str, ...] source_policy: Optional[Policy] - source_results: Optional[List["PolicyResult"]] + source_results: Optional[list["PolicyResult"]] def __init__(self, passing: bool, *messages: str): super().__init__() diff --git a/authentik/root/settings.py b/authentik/root/settings.py index 4cc359628..4238dc18f 100644 --- a/authentik/root/settings.py +++ b/authentik/root/settings.py @@ -93,11 +93,12 @@ INSTALLED_APPS = [ "authentik.lib.apps.AuthentikLibConfig", "authentik.policies.apps.AuthentikPoliciesConfig", "authentik.policies.dummy.apps.AuthentikPolicyDummyConfig", + "authentik.policies.event_matcher.apps.AuthentikPoliciesEventMatcherConfig", "authentik.policies.expiry.apps.AuthentikPolicyExpiryConfig", "authentik.policies.expression.apps.AuthentikPolicyExpressionConfig", + "authentik.policies.group_membership.apps.AuthentikPoliciesGroupMembershipConfig", "authentik.policies.hibp.apps.AuthentikPolicyHIBPConfig", "authentik.policies.password.apps.AuthentikPoliciesPasswordConfig", - "authentik.policies.group_membership.apps.AuthentikPoliciesGroupMembershipConfig", "authentik.policies.reputation.apps.AuthentikPolicyReputationConfig", "authentik.providers.proxy.apps.AuthentikProviderProxyConfig", "authentik.providers.oauth2.apps.AuthentikProviderOAuth2Config", diff --git a/authentik/stages/email/management/commands/test_email.py b/authentik/stages/email/management/commands/test_email.py index 02a190c0a..a2c36d545 100644 --- a/authentik/stages/email/management/commands/test_email.py +++ b/authentik/stages/email/management/commands/test_email.py @@ -30,9 +30,7 @@ class Command(BaseCommand): # pragma: no cover ) try: # pyright: reportGeneralTypeIssues=false - send_mail( # pylint: disable=no-value-for-parameter - stage.pk, message.__dict__ - ) + send_mail(message.__dict__, stage.pk) finally: if delete_stage: stage.delete() diff --git a/authentik/stages/email/tasks.py b/authentik/stages/email/tasks.py index 2903bf002..0c62ab511 100644 --- a/authentik/stages/email/tasks.py +++ b/authentik/stages/email/tasks.py @@ -1,7 +1,7 @@ """email stage tasks""" from email.utils import make_msgid from smtplib import SMTPException -from typing import Any, Dict, List +from typing import Any, Optional from celery import group from django.core.mail import EmailMultiAlternatives @@ -16,11 +16,11 @@ from authentik.stages.email.models import EmailStage LOGGER = get_logger() -def send_mails(stage: EmailStage, *messages: List[EmailMultiAlternatives]): +def send_mails(stage: EmailStage, *messages: list[EmailMultiAlternatives]): """Wrapper to convert EmailMessage to dict and send it from worker""" tasks = [] for message in messages: - tasks.append(send_mail.s(stage.pk, message.__dict__)) + tasks.append(send_mail.s(message.__dict__, stage.pk)) lazy_group = group(*tasks) promise = lazy_group() return promise @@ -35,13 +35,18 @@ def send_mails(stage: EmailStage, *messages: List[EmailMultiAlternatives]): retry_backoff=True, base=MonitoredTask, ) -def send_mail(self: MonitoredTask, email_stage_pk: int, message: Dict[Any, Any]): +def send_mail( + self: MonitoredTask, message: dict[Any, Any], email_stage_pk: Optional[int] = None +): """Send Email for Email Stage. Retries are scheduled automatically.""" self.save_on_success = False message_id = make_msgid(domain=DNS_NAME) self.set_uid(slugify(message_id.replace(".", "_").replace("@", "_"))) try: - stage: EmailStage = EmailStage.objects.get(pk=email_stage_pk) + if not email_stage_pk: + stage: EmailStage = EmailStage() + else: + stage: EmailStage = EmailStage.objects.get(pk=email_stage_pk) backend = stage.backend backend.open() # Since django's EmailMessage objects are not JSON serialisable, diff --git a/lifecycle/bootstrap.sh b/lifecycle/bootstrap.sh index 3d87ac0ce..12eec0a6e 100755 --- a/lifecycle/bootstrap.sh +++ b/lifecycle/bootstrap.sh @@ -4,7 +4,7 @@ printf '{"event": "Bootstrap completed", "level": "info", "logger": "bootstrap", if [[ "$1" == "server" ]]; then gunicorn -c /lifecycle/gunicorn.conf.py authentik.root.asgi:application elif [[ "$1" == "worker" ]]; then - celery -A authentik.root.celery worker --autoscale 3,1 -E -B -s /tmp/celerybeat-schedule -Q authentik,authentik_scheduled + celery -A authentik.root.celery worker --autoscale 3,1 -E -B -s /tmp/celerybeat-schedule -Q authentik,authentik_scheduled,authentik_events elif [[ "$1" == "migrate" ]]; then # Run system migrations first, run normal migrations after python -m lifecycle.migrate diff --git a/swagger.yaml b/swagger.yaml index e0305698f..41339aa1f 100755 --- a/swagger.yaml +++ b/swagger.yaml @@ -979,6 +979,413 @@ paths: required: true type: string format: uuid + /events/notifications/: + get: + operationId: events_notifications_list + description: Notification Viewset + parameters: + - name: ordering + in: query + description: Which field to use when ordering the results. + required: false + type: string + - name: search + in: query + description: A search term. + required: false + type: string + - name: page + in: query + description: A page number within the paginated result set. + required: false + type: integer + - name: page_size + in: query + description: Number of results to return per page. + required: false + type: integer + responses: + '200': + description: '' + schema: + required: + - count + - results + type: object + properties: + count: + type: integer + next: + type: string + format: uri + x-nullable: true + previous: + type: string + format: uri + x-nullable: true + results: + type: array + items: + $ref: '#/definitions/Notification' + tags: + - events + post: + operationId: events_notifications_create + description: Notification Viewset + parameters: + - name: data + in: body + required: true + schema: + $ref: '#/definitions/Notification' + responses: + '201': + description: '' + schema: + $ref: '#/definitions/Notification' + tags: + - events + parameters: [] + /events/notifications/{uuid}/: + get: + operationId: events_notifications_read + description: Notification Viewset + parameters: [] + responses: + '200': + description: '' + schema: + $ref: '#/definitions/Notification' + tags: + - events + put: + operationId: events_notifications_update + description: Notification Viewset + parameters: + - name: data + in: body + required: true + schema: + $ref: '#/definitions/Notification' + responses: + '200': + description: '' + schema: + $ref: '#/definitions/Notification' + tags: + - events + patch: + operationId: events_notifications_partial_update + description: Notification Viewset + parameters: + - name: data + in: body + required: true + schema: + $ref: '#/definitions/Notification' + responses: + '200': + description: '' + schema: + $ref: '#/definitions/Notification' + tags: + - events + delete: + operationId: events_notifications_delete + description: Notification Viewset + parameters: [] + responses: + '204': + description: '' + tags: + - events + parameters: + - name: uuid + in: path + description: A UUID string identifying this Notification. + required: true + type: string + format: uuid + /events/transports/: + get: + operationId: events_transports_list + description: NotificationTransport Viewset + parameters: + - name: ordering + in: query + description: Which field to use when ordering the results. + required: false + type: string + - name: search + in: query + description: A search term. + required: false + type: string + - name: page + in: query + description: A page number within the paginated result set. + required: false + type: integer + - name: page_size + in: query + description: Number of results to return per page. + required: false + type: integer + responses: + '200': + description: '' + schema: + required: + - count + - results + type: object + properties: + count: + type: integer + next: + type: string + format: uri + x-nullable: true + previous: + type: string + format: uri + x-nullable: true + results: + type: array + items: + $ref: '#/definitions/NotificationTransport' + tags: + - events + post: + operationId: events_transports_create + description: NotificationTransport Viewset + parameters: + - name: data + in: body + required: true + schema: + $ref: '#/definitions/NotificationTransport' + responses: + '201': + description: '' + schema: + $ref: '#/definitions/NotificationTransport' + tags: + - events + parameters: [] + /events/transports/{uuid}/: + get: + operationId: events_transports_read + description: NotificationTransport Viewset + parameters: [] + responses: + '200': + description: '' + schema: + $ref: '#/definitions/NotificationTransport' + tags: + - events + put: + operationId: events_transports_update + description: NotificationTransport Viewset + parameters: + - name: data + in: body + required: true + schema: + $ref: '#/definitions/NotificationTransport' + responses: + '200': + description: '' + schema: + $ref: '#/definitions/NotificationTransport' + tags: + - events + patch: + operationId: events_transports_partial_update + description: NotificationTransport Viewset + parameters: + - name: data + in: body + required: true + schema: + $ref: '#/definitions/NotificationTransport' + responses: + '200': + description: '' + schema: + $ref: '#/definitions/NotificationTransport' + tags: + - events + delete: + operationId: events_transports_delete + description: NotificationTransport Viewset + parameters: [] + responses: + '204': + description: '' + tags: + - events + parameters: + - name: uuid + in: path + description: A UUID string identifying this Notification Transport. + required: true + type: string + format: uuid + /events/transports/{uuid}/test/: + post: + operationId: events_transports_test + description: |- + Send example notification using selected transport. Requires + Modify permissions. + parameters: + - name: data + in: body + required: true + schema: + $ref: '#/definitions/NotificationTransport' + responses: + '201': + description: '' + schema: + $ref: '#/definitions/NotificationTransport' + tags: + - events + parameters: + - name: uuid + in: path + description: A UUID string identifying this Notification Transport. + required: true + type: string + format: uuid + /events/triggers/: + get: + operationId: events_triggers_list + description: NotificationTrigger Viewset + parameters: + - name: ordering + in: query + description: Which field to use when ordering the results. + required: false + type: string + - name: search + in: query + description: A search term. + required: false + type: string + - name: page + in: query + description: A page number within the paginated result set. + required: false + type: integer + - name: page_size + in: query + description: Number of results to return per page. + required: false + type: integer + responses: + '200': + description: '' + schema: + required: + - count + - results + type: object + properties: + count: + type: integer + next: + type: string + format: uri + x-nullable: true + previous: + type: string + format: uri + x-nullable: true + results: + type: array + items: + $ref: '#/definitions/NotificationTrigger' + tags: + - events + post: + operationId: events_triggers_create + description: NotificationTrigger Viewset + parameters: + - name: data + in: body + required: true + schema: + $ref: '#/definitions/NotificationTrigger' + responses: + '201': + description: '' + schema: + $ref: '#/definitions/NotificationTrigger' + tags: + - events + parameters: [] + /events/triggers/{pbm_uuid}/: + get: + operationId: events_triggers_read + description: NotificationTrigger Viewset + parameters: [] + responses: + '200': + description: '' + schema: + $ref: '#/definitions/NotificationTrigger' + tags: + - events + put: + operationId: events_triggers_update + description: NotificationTrigger Viewset + parameters: + - name: data + in: body + required: true + schema: + $ref: '#/definitions/NotificationTrigger' + responses: + '200': + description: '' + schema: + $ref: '#/definitions/NotificationTrigger' + tags: + - events + patch: + operationId: events_triggers_partial_update + description: NotificationTrigger Viewset + parameters: + - name: data + in: body + required: true + schema: + $ref: '#/definitions/NotificationTrigger' + responses: + '200': + description: '' + schema: + $ref: '#/definitions/NotificationTrigger' + tags: + - events + delete: + operationId: events_triggers_delete + description: NotificationTrigger Viewset + parameters: [] + responses: + '204': + description: '' + tags: + - events + parameters: + - name: pbm_uuid + in: path + description: A UUID string identifying this Notification Trigger. + required: true + type: string + format: uuid /flows/bindings/: get: operationId: flows_bindings_list @@ -2287,6 +2694,133 @@ paths: required: true type: string format: uuid + /policies/event_matcher/: + get: + operationId: policies_event_matcher_list + description: Event Matcher Policy Viewset + parameters: + - name: ordering + in: query + description: Which field to use when ordering the results. + required: false + type: string + - name: search + in: query + description: A search term. + required: false + type: string + - name: page + in: query + description: A page number within the paginated result set. + required: false + type: integer + - name: page_size + in: query + description: Number of results to return per page. + required: false + type: integer + responses: + '200': + description: '' + schema: + required: + - count + - results + type: object + properties: + count: + type: integer + next: + type: string + format: uri + x-nullable: true + previous: + type: string + format: uri + x-nullable: true + results: + type: array + items: + $ref: '#/definitions/EventMatcherPolicy' + tags: + - policies + post: + operationId: policies_event_matcher_create + description: Event Matcher Policy Viewset + parameters: + - name: data + in: body + required: true + schema: + $ref: '#/definitions/EventMatcherPolicy' + responses: + '201': + description: '' + schema: + $ref: '#/definitions/EventMatcherPolicy' + tags: + - policies + parameters: [] + /policies/event_matcher/{policy_uuid}/: + get: + operationId: policies_event_matcher_read + description: Event Matcher Policy Viewset + parameters: [] + responses: + '200': + description: '' + schema: + $ref: '#/definitions/EventMatcherPolicy' + tags: + - policies + put: + operationId: policies_event_matcher_update + description: Event Matcher Policy Viewset + parameters: + - name: data + in: body + required: true + schema: + $ref: '#/definitions/EventMatcherPolicy' + responses: + '200': + description: '' + schema: + $ref: '#/definitions/EventMatcherPolicy' + tags: + - policies + patch: + operationId: policies_event_matcher_partial_update + description: Event Matcher Policy Viewset + parameters: + - name: data + in: body + required: true + schema: + $ref: '#/definitions/EventMatcherPolicy' + responses: + '200': + description: '' + schema: + $ref: '#/definitions/EventMatcherPolicy' + tags: + - policies + delete: + operationId: policies_event_matcher_delete + description: Event Matcher Policy Viewset + parameters: [] + responses: + '204': + description: '' + tags: + - policies + parameters: + - name: policy_uuid + in: path + description: A UUID string identifying this Group Membership Policy. + required: true + type: string + format: uuid /policies/expression/: get: operationId: policies_expression_list @@ -7083,6 +7617,105 @@ definitions: unique_users: title: Unique users type: integer + Notification: + description: Notification Serializer + required: + - severity + - body + type: object + properties: + pk: + title: Uuid + type: string + format: uuid + readOnly: true + severity: + title: Severity + type: string + enum: + - notice + - warning + - alert + body: + title: Body + type: string + minLength: 1 + created: + title: Created + type: string + format: date-time + readOnly: true + event: + title: Event + type: string + format: uuid + x-nullable: true + seen: + title: Seen + type: boolean + NotificationTransport: + description: NotificationTransport Serializer + required: + - name + - mode + type: object + properties: + pk: + title: Uuid + type: string + format: uuid + readOnly: true + name: + title: Name + type: string + minLength: 1 + mode: + title: Mode + type: string + enum: + - webhook + - webhook_slack + - email + webhook_url: + title: Webhook url + type: string + NotificationTrigger: + description: NotificationTrigger Serializer + required: + - name + - transports + type: object + properties: + pk: + title: Pbm uuid + type: string + format: uuid + readOnly: true + name: + title: Name + type: string + minLength: 1 + transports: + description: Select which transports should be used to notify the user. If + none are selected, the notification will only be shown in the authentik + UI. + type: array + items: + description: Select which transports should be used to notify the user. + If none are selected, the notification will only be shown in the authentik + UI. + type: string + format: uuid + uniqueItems: true + severity: + title: Severity + description: Controls which severity level the created notifications will + have. + type: string + enum: + - notice + - warning + - alert Stage: title: Stage obj description: Stage Serializer @@ -7557,6 +8190,96 @@ definitions: type: integer maximum: 2147483647 minimum: -2147483648 + EventMatcherPolicy: + description: Event Matcher Policy Serializer + type: object + properties: + pk: + title: Policy uuid + type: string + format: uuid + readOnly: true + name: + title: Name + type: string + x-nullable: true + action: + title: Action + description: Match created events with this action type. When left empty, + all action types will be matched. + type: string + enum: + - login + - login_failed + - logout + - user_write + - suspicious_request + - password_set + - token_view + - invitation_used + - authorize_application + - source_linked + - impersonation_started + - impersonation_ended + - policy_execution + - policy_exception + - property_mapping_exception + - configuration_error + - model_created + - model_updated + - model_deleted + - update_available + - custom_ + client_ip: + title: Client ip + description: Matches Event's Client IP (strict matching, for network matching + use an Expression Policy) + type: string + app: + title: App + description: Match events created by selected application. When left empty, + all applications are matched. + type: string + enum: + - authentik.admin + - authentik.api + - authentik.events + - authentik.crypto + - authentik.flows + - authentik.outposts + - authentik.lib + - authentik.policies + - authentik.policies.dummy + - authentik.policies.event_matcher + - authentik.policies.expiry + - authentik.policies.expression + - authentik.policies.group_membership + - authentik.policies.hibp + - authentik.policies.password + - authentik.policies.reputation + - authentik.providers.proxy + - authentik.providers.oauth2 + - authentik.providers.saml + - authentik.recovery + - authentik.sources.ldap + - authentik.sources.oauth + - authentik.sources.saml + - authentik.stages.captcha + - authentik.stages.consent + - authentik.stages.dummy + - authentik.stages.email + - authentik.stages.prompt + - authentik.stages.identification + - authentik.stages.invitation + - authentik.stages.user_delete + - authentik.stages.user_login + - authentik.stages.user_logout + - authentik.stages.user_write + - authentik.stages.otp_static + - authentik.stages.otp_time + - authentik.stages.otp_validate + - authentik.stages.password + - authentik.core ExpressionPolicy: description: Group Membership Policy Serializer required: