events: Notifications (#418)

* events: initial alerting implementation

* policies: move error handling to process, ensure policy UUID is saved

* policies: add tests for error handling in PolicyProcess

* events: improve loop detection

* events: add API for action and trigger

* policies: ensure http_request is not used in context

* events: adjust unittests for user handling

* policies/event_matcher: add policy type

* events: add API tests

* events: add middleware tests

* core: make application's provider not required

* outposts: allow blank kubeconfig

* outposts: validate kubeconfig before saving

* api: fix formatting

* stages/invitation: remove invitation_created signal as model_created functions the same

* stages/invitation: ensure created_by is set when creating from API

* events: rebase migrations on master

* events: fix missing Alerts from API

* policies: fix unittests

* events: add tests for alerts

* events: rename from alerting to notifications

* events: add ability to specify severity of notification created

* policies/event_matcher: Add app field to match on event app

* policies/event_matcher: fix EventMatcher not being included in API

* core: use objects.none() when get_queryset is used

* events: use m2m for multiple transports, create notification object in task

* events: add default triggers

* events: fix migrations return value

* events: fix notification_transport not being in the correct queue

* stages/email: allow sending of email without backend

* events: implement sending via webhook + slack/discord + email
This commit is contained in:
Jens L 2021-01-11 18:43:59 +01:00 committed by GitHub
parent f8a426f0e8
commit 1ccf6dcf6f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
40 changed files with 2074 additions and 84 deletions

View file

@ -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)

View file

@ -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):

View file

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

View file

@ -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"

View file

@ -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):

View file

View file

@ -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)

View file

@ -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))

View file

@ -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

View file

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

View file

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

View file

@ -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"<Event action={self.action} user={self.user} context={self.context}>"
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")

View file

@ -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)

80
authentik/events/tasks.py Normal file
View file

@ -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

View file

@ -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)

View file

@ -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)

View file

@ -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()
)

View file

@ -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)

View file

@ -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

View file

@ -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

View file

@ -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"

View file

@ -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(),
}

View file

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

View file

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

View file

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

View file

@ -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")

View file

@ -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)

View file

@ -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",

View file

@ -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"""

View file

@ -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

View file

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

View file

@ -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__()

View file

@ -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",

View file

@ -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()

View file

@ -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,12 +35,17 @@ 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:
if not email_stage_pk:
stage: EmailStage = EmailStage()
else:
stage: EmailStage = EmailStage.objects.get(pk=email_stage_pk)
backend = stage.backend
backend.open()

View file

@ -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

View file

@ -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: