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:
parent
f8a426f0e8
commit
1ccf6dcf6f
|
@ -19,7 +19,10 @@ from authentik.core.api.sources import SourceViewSet
|
||||||
from authentik.core.api.tokens import TokenViewSet
|
from authentik.core.api.tokens import TokenViewSet
|
||||||
from authentik.core.api.users import UserViewSet
|
from authentik.core.api.users import UserViewSet
|
||||||
from authentik.crypto.api import CertificateKeyPairViewSet
|
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 (
|
from authentik.flows.api import (
|
||||||
FlowCacheViewSet,
|
FlowCacheViewSet,
|
||||||
FlowStageBindingViewSet,
|
FlowStageBindingViewSet,
|
||||||
|
@ -37,6 +40,7 @@ from authentik.policies.api import (
|
||||||
PolicyViewSet,
|
PolicyViewSet,
|
||||||
)
|
)
|
||||||
from authentik.policies.dummy.api import DummyPolicyViewSet
|
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.expiry.api import PasswordExpiryPolicyViewSet
|
||||||
from authentik.policies.expression.api import ExpressionPolicyViewSet
|
from authentik.policies.expression.api import ExpressionPolicyViewSet
|
||||||
from authentik.policies.group_membership.api import GroupMembershipPolicyViewSet
|
from authentik.policies.group_membership.api import GroupMembershipPolicyViewSet
|
||||||
|
@ -97,6 +101,9 @@ router.register("flows/bindings", FlowStageBindingViewSet)
|
||||||
router.register("crypto/certificatekeypairs", CertificateKeyPairViewSet)
|
router.register("crypto/certificatekeypairs", CertificateKeyPairViewSet)
|
||||||
|
|
||||||
router.register("events/events", EventViewSet)
|
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/all", SourceViewSet)
|
||||||
router.register("sources/ldap", LDAPSourceViewSet)
|
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/cached", PolicyCacheViewSet, basename="policies_cache")
|
||||||
router.register("policies/bindings", PolicyBindingViewSet)
|
router.register("policies/bindings", PolicyBindingViewSet)
|
||||||
router.register("policies/expression", ExpressionPolicyViewSet)
|
router.register("policies/expression", ExpressionPolicyViewSet)
|
||||||
|
router.register("policies/event_matcher", EventMatcherPolicyViewSet)
|
||||||
router.register("policies/group_membership", GroupMembershipPolicyViewSet)
|
router.register("policies/group_membership", GroupMembershipPolicyViewSet)
|
||||||
router.register("policies/haveibeenpwned", HaveIBeenPwendPolicyViewSet)
|
router.register("policies/haveibeenpwned", HaveIBeenPwendPolicyViewSet)
|
||||||
router.register("policies/password_expiry", PasswordExpiryPolicyViewSet)
|
router.register("policies/password_expiry", PasswordExpiryPolicyViewSet)
|
||||||
|
|
|
@ -23,7 +23,7 @@ class PropertyMappingSerializer(ModelSerializer):
|
||||||
class PropertyMappingViewSet(ReadOnlyModelViewSet):
|
class PropertyMappingViewSet(ReadOnlyModelViewSet):
|
||||||
"""PropertyMapping Viewset"""
|
"""PropertyMapping Viewset"""
|
||||||
|
|
||||||
queryset = PropertyMapping.objects.all()
|
queryset = PropertyMapping.objects.none()
|
||||||
serializer_class = PropertyMappingSerializer
|
serializer_class = PropertyMappingSerializer
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
|
|
|
@ -39,7 +39,7 @@ class ProviderSerializer(ModelSerializer, MetaNameSerializer):
|
||||||
class ProviderViewSet(ModelViewSet):
|
class ProviderViewSet(ModelViewSet):
|
||||||
"""Provider Viewset"""
|
"""Provider Viewset"""
|
||||||
|
|
||||||
queryset = Provider.objects.all()
|
queryset = Provider.objects.none()
|
||||||
serializer_class = ProviderSerializer
|
serializer_class = ProviderSerializer
|
||||||
filterset_fields = {
|
filterset_fields = {
|
||||||
"application": ["isnull"],
|
"application": ["isnull"],
|
||||||
|
|
|
@ -31,7 +31,7 @@ class SourceSerializer(ModelSerializer, MetaNameSerializer):
|
||||||
class SourceViewSet(ReadOnlyModelViewSet):
|
class SourceViewSet(ReadOnlyModelViewSet):
|
||||||
"""Source Viewset"""
|
"""Source Viewset"""
|
||||||
|
|
||||||
queryset = Source.objects.all()
|
queryset = Source.objects.none()
|
||||||
serializer_class = SourceSerializer
|
serializer_class = SourceSerializer
|
||||||
lookup_field = "slug"
|
lookup_field = "slug"
|
||||||
|
|
||||||
|
|
|
@ -34,7 +34,7 @@ class UserSerializer(ModelSerializer):
|
||||||
class UserViewSet(ModelViewSet):
|
class UserViewSet(ModelViewSet):
|
||||||
"""User Viewset"""
|
"""User Viewset"""
|
||||||
|
|
||||||
queryset = User.objects.all()
|
queryset = User.objects.none()
|
||||||
serializer_class = UserSerializer
|
serializer_class = UserSerializer
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
|
|
0
authentik/events/api/__init__.py
Normal file
0
authentik/events/api/__init__.py
Normal file
33
authentik/events/api/notification.py
Normal file
33
authentik/events/api/notification.py
Normal 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)
|
53
authentik/events/api/notification_transport.py
Normal file
53
authentik/events/api/notification_transport.py
Normal 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))
|
26
authentik/events/api/notification_trigger.py
Normal file
26
authentik/events/api/notification_trigger.py
Normal 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
|
|
@ -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",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
|
@ -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),
|
||||||
|
]
|
|
@ -9,15 +9,20 @@ from django.core.exceptions import ValidationError
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.http import HttpRequest
|
from django.http import HttpRequest
|
||||||
from django.utils.translation import gettext as _
|
from django.utils.translation import gettext as _
|
||||||
|
from requests import post
|
||||||
from structlog.stdlib import get_logger
|
from structlog.stdlib import get_logger
|
||||||
|
|
||||||
|
from authentik import __version__
|
||||||
from authentik.core.middleware import (
|
from authentik.core.middleware import (
|
||||||
SESSION_IMPERSONATE_ORIGINAL_USER,
|
SESSION_IMPERSONATE_ORIGINAL_USER,
|
||||||
SESSION_IMPERSONATE_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.events.utils import cleanse_dict, get_user, sanitize_dict
|
||||||
from authentik.lib.utils.http import get_client_ip
|
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")
|
LOGGER = get_logger("authentik.events")
|
||||||
|
|
||||||
|
@ -104,10 +109,12 @@ class Event(models.Model):
|
||||||
Events independently from requests.
|
Events independently from requests.
|
||||||
`user` arguments optionally overrides user from requests."""
|
`user` arguments optionally overrides user from requests."""
|
||||||
if hasattr(request, "user"):
|
if hasattr(request, "user"):
|
||||||
self.user = get_user(
|
original_user = None
|
||||||
request.user,
|
if hasattr(request, "session"):
|
||||||
request.session.get(SESSION_IMPERSONATE_ORIGINAL_USER, None),
|
original_user = request.session.get(
|
||||||
|
SESSION_IMPERSONATE_ORIGINAL_USER, None
|
||||||
)
|
)
|
||||||
|
self.user = get_user(request.user, original_user)
|
||||||
if user:
|
if user:
|
||||||
self.user = get_user(user)
|
self.user = get_user(user)
|
||||||
# Check if we're currently impersonating, and add that user
|
# Check if we're currently impersonating, and add that user
|
||||||
|
@ -139,7 +146,189 @@ class Event(models.Model):
|
||||||
)
|
)
|
||||||
return super().save(*args, **kwargs)
|
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:
|
class Meta:
|
||||||
|
|
||||||
verbose_name = _("Event")
|
verbose_name = _("Event")
|
||||||
verbose_name_plural = _("Events")
|
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")
|
||||||
|
|
|
@ -7,12 +7,14 @@ from django.contrib.auth.signals import (
|
||||||
user_logged_out,
|
user_logged_out,
|
||||||
user_login_failed,
|
user_login_failed,
|
||||||
)
|
)
|
||||||
|
from django.db.models.signals import post_save
|
||||||
from django.dispatch import receiver
|
from django.dispatch import receiver
|
||||||
from django.http import HttpRequest
|
from django.http import HttpRequest
|
||||||
|
|
||||||
from authentik.core.models import User
|
from authentik.core.models import User
|
||||||
from authentik.core.signals import password_changed
|
from authentik.core.signals import password_changed
|
||||||
from authentik.events.models import Event, EventAction
|
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.models import Invitation
|
||||||
from authentik.stages.invitation.signals import invitation_used
|
from authentik.stages.invitation.signals import invitation_used
|
||||||
from authentik.stages.user_write.signals import user_write
|
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"""
|
"""Log password change"""
|
||||||
thread = EventNewThread(EventAction.PASSWORD_SET, None, user=user)
|
thread = EventNewThread(EventAction.PASSWORD_SET, None, user=user)
|
||||||
thread.run()
|
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
80
authentik/events/tasks.py
Normal 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
|
24
authentik/events/tests/test_api.py
Normal file
24
authentik/events/tests/test_api.py
Normal 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)
|
|
@ -1,9 +1,10 @@
|
||||||
"""events event tests"""
|
"""event tests"""
|
||||||
|
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
from guardian.shortcuts import get_anonymous_user
|
from guardian.shortcuts import get_anonymous_user
|
||||||
|
|
||||||
|
from authentik.core.models import Group
|
||||||
from authentik.events.models import Event
|
from authentik.events.models import Event
|
||||||
from authentik.policies.dummy.models import DummyPolicy
|
from authentik.policies.dummy.models import DummyPolicy
|
||||||
|
|
||||||
|
@ -13,14 +14,24 @@ class TestEvents(TestCase):
|
||||||
|
|
||||||
def test_new_with_model(self):
|
def test_new_with_model(self):
|
||||||
"""Create a new Event passing a model as kwarg"""
|
"""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
|
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(
|
self.assertEqual(
|
||||||
event.context.get("test").get("model").get("app"),
|
event.context.get("test").get("model").get("app"),
|
||||||
model_content_type.app_label,
|
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):
|
def test_new_with_uuid_model(self):
|
||||||
"""Create a new Event passing a model (with UUID PK) as kwarg"""
|
"""Create a new Event passing a model (with UUID PK) as kwarg"""
|
||||||
temp_model = DummyPolicy.objects.create(name="test", result=True)
|
temp_model = DummyPolicy.objects.create(name="test", result=True)
|
||||||
|
|
48
authentik/events/tests/test_middleware.py
Normal file
48
authentik/events/tests/test_middleware.py
Normal 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()
|
||||||
|
)
|
77
authentik/events/tests/test_notifications.py
Normal file
77
authentik/events/tests/test_notifications.py
Normal 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)
|
|
@ -5,8 +5,10 @@ from typing import Any, Dict, Optional
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from django.contrib.auth.models import AnonymousUser
|
from django.contrib.auth.models import AnonymousUser
|
||||||
|
from django.core.handlers.wsgi import WSGIRequest
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.db.models.base import Model
|
from django.db.models.base import Model
|
||||||
|
from django.http.request import HttpRequest
|
||||||
from django.views.debug import SafeExceptionReporterFilter
|
from django.views.debug import SafeExceptionReporterFilter
|
||||||
from guardian.utils import get_anonymous_user
|
from guardian.utils import get_anonymous_user
|
||||||
|
|
||||||
|
@ -83,10 +85,14 @@ def sanitize_dict(source: Dict[Any, Any]) -> Dict[Any, Any]:
|
||||||
value = asdict(value)
|
value = asdict(value)
|
||||||
if isinstance(value, dict):
|
if isinstance(value, dict):
|
||||||
final_dict[key] = sanitize_dict(value)
|
final_dict[key] = sanitize_dict(value)
|
||||||
|
elif isinstance(value, User):
|
||||||
|
final_dict[key] = sanitize_dict(get_user(value))
|
||||||
elif isinstance(value, models.Model):
|
elif isinstance(value, models.Model):
|
||||||
final_dict[key] = sanitize_dict(model_to_dict(value))
|
final_dict[key] = sanitize_dict(model_to_dict(value))
|
||||||
elif isinstance(value, UUID):
|
elif isinstance(value, UUID):
|
||||||
final_dict[key] = value.hex
|
final_dict[key] = value.hex
|
||||||
|
elif isinstance(value, (HttpRequest, WSGIRequest)):
|
||||||
|
continue
|
||||||
else:
|
else:
|
||||||
final_dict[key] = value
|
final_dict[key] = value
|
||||||
return final_dict
|
return final_dict
|
||||||
|
|
0
authentik/policies/event_matcher/__init__.py
Normal file
0
authentik/policies/event_matcher/__init__.py
Normal file
25
authentik/policies/event_matcher/api.py
Normal file
25
authentik/policies/event_matcher/api.py
Normal 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
|
11
authentik/policies/event_matcher/apps.py
Normal file
11
authentik/policies/event_matcher/apps.py
Normal 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"
|
23
authentik/policies/event_matcher/forms.py
Normal file
23
authentik/policies/event_matcher/forms.py
Normal 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(),
|
||||||
|
}
|
70
authentik/policies/event_matcher/migrations/0001_initial.py
Normal file
70
authentik/policies/event_matcher/migrations/0001_initial.py
Normal 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",),
|
||||||
|
),
|
||||||
|
]
|
|
@ -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"),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
|
@ -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)",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
91
authentik/policies/event_matcher/models.py
Normal file
91
authentik/policies/event_matcher/models.py
Normal 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")
|
68
authentik/policies/event_matcher/tests.py
Normal file
68
authentik/policies/event_matcher/tests.py
Normal 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)
|
|
@ -1,16 +1,14 @@
|
||||||
"""authentik expression policy evaluator"""
|
"""authentik expression policy evaluator"""
|
||||||
from ipaddress import ip_address, ip_network
|
from ipaddress import ip_address, ip_network
|
||||||
from traceback import format_tb
|
|
||||||
from typing import TYPE_CHECKING, List, Optional
|
from typing import TYPE_CHECKING, List, Optional
|
||||||
|
|
||||||
from django.http import HttpRequest
|
from django.http import HttpRequest
|
||||||
from structlog.stdlib import get_logger
|
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.flows.planner import PLAN_CONTEXT_SSO
|
||||||
from authentik.lib.expression.evaluator import BaseEvaluator
|
from authentik.lib.expression.evaluator import BaseEvaluator
|
||||||
from authentik.lib.utils.http import get_client_ip
|
from authentik.lib.utils.http import get_client_ip
|
||||||
|
from authentik.policies.exceptions import PolicyException
|
||||||
from authentik.policies.types import PolicyRequest, PolicyResult
|
from authentik.policies.types import PolicyRequest, PolicyResult
|
||||||
|
|
||||||
LOGGER = get_logger()
|
LOGGER = get_logger()
|
||||||
|
@ -57,32 +55,22 @@ class PolicyEvaluator(BaseEvaluator):
|
||||||
|
|
||||||
def handle_error(self, exc: Exception, expression_source: str):
|
def handle_error(self, exc: Exception, expression_source: str):
|
||||||
"""Exception Handler"""
|
"""Exception Handler"""
|
||||||
error_string = "\n".join(format_tb(exc.__traceback__) + [str(exc)])
|
raise PolicyException(str(exc)) from 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()
|
|
||||||
|
|
||||||
def evaluate(self, expression_source: str) -> PolicyResult:
|
def evaluate(self, expression_source: str) -> PolicyResult:
|
||||||
"""Parse and evaluate expression. Policy is expected to return a truthy object.
|
"""Parse and evaluate expression. Policy is expected to return a truthy object.
|
||||||
Messages can be added using 'do ak_message()'."""
|
Messages can be added using 'do ak_message()'."""
|
||||||
try:
|
try:
|
||||||
result = super().evaluate(expression_source)
|
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
|
except Exception as exc: # pylint: disable=broad-except
|
||||||
LOGGER.warning("Expression error", exc=exc)
|
LOGGER.warning("Expression error", exc=exc)
|
||||||
return PolicyResult(False, str(exc))
|
return PolicyResult(False, str(exc))
|
||||||
else:
|
else:
|
||||||
policy_result = PolicyResult(False)
|
policy_result = PolicyResult(False, *self._messages)
|
||||||
policy_result.messages = tuple(self._messages)
|
|
||||||
if result is None:
|
if result is None:
|
||||||
LOGGER.warning(
|
LOGGER.warning(
|
||||||
"Expression policy returned None",
|
"Expression policy returned None",
|
||||||
|
|
|
@ -3,7 +3,7 @@ from django.core.exceptions import ValidationError
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
from guardian.shortcuts import get_anonymous_user
|
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.evaluator import PolicyEvaluator
|
||||||
from authentik.policies.expression.models import ExpressionPolicy
|
from authentik.policies.expression.models import ExpressionPolicy
|
||||||
from authentik.policies.types import PolicyRequest
|
from authentik.policies.types import PolicyRequest
|
||||||
|
@ -44,30 +44,8 @@ class TestEvaluator(TestCase):
|
||||||
template = ";"
|
template = ";"
|
||||||
evaluator = PolicyEvaluator("test")
|
evaluator = PolicyEvaluator("test")
|
||||||
evaluator.set_policy_request(self.request)
|
evaluator.set_policy_request(self.request)
|
||||||
result = evaluator.evaluate(template)
|
with self.assertRaises(PolicyException):
|
||||||
self.assertEqual(result.passing, False)
|
evaluator.evaluate(template)
|
||||||
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()
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_validate(self):
|
def test_validate(self):
|
||||||
"""test validate"""
|
"""test validate"""
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
"""authentik policy task"""
|
"""authentik policy task"""
|
||||||
from multiprocessing import Process
|
from multiprocessing import Process
|
||||||
from multiprocessing.connection import Connection
|
from multiprocessing.connection import Connection
|
||||||
|
from traceback import format_tb
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
|
@ -19,7 +20,7 @@ LOGGER = get_logger()
|
||||||
def cache_key(binding: PolicyBinding, request: PolicyRequest) -> str:
|
def cache_key(binding: PolicyBinding, request: PolicyRequest) -> str:
|
||||||
"""Generate Cache key for policy"""
|
"""Generate Cache key for policy"""
|
||||||
prefix = f"policy_{binding.policy_binding_uuid.hex}_{binding.policy.pk.hex}"
|
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}"
|
prefix += f"_{request.http_request.session.session_key}"
|
||||||
if request.user:
|
if request.user:
|
||||||
prefix += f"#{request.user.pk}"
|
prefix += f"#{request.user.pk}"
|
||||||
|
@ -47,6 +48,23 @@ class PolicyProcess(Process):
|
||||||
if connection:
|
if connection:
|
||||||
self.connection = 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:
|
def execute(self) -> PolicyResult:
|
||||||
"""Run actual policy, returns result"""
|
"""Run actual policy, returns result"""
|
||||||
LOGGER.debug(
|
LOGGER.debug(
|
||||||
|
@ -58,15 +76,11 @@ class PolicyProcess(Process):
|
||||||
try:
|
try:
|
||||||
policy_result = self.binding.policy.passes(self.request)
|
policy_result = self.binding.policy.passes(self.request)
|
||||||
if self.binding.policy.execution_logging:
|
if self.binding.policy.execution_logging:
|
||||||
event = Event.new(
|
self.create_event(EventAction.POLICY_EXECUTION, result=policy_result)
|
||||||
EventAction.POLICY_EXECUTION,
|
|
||||||
request=self.request,
|
|
||||||
binding=self.binding,
|
|
||||||
result=policy_result,
|
|
||||||
)
|
|
||||||
event.set_user(self.request.user)
|
|
||||||
event.save()
|
|
||||||
except PolicyException as exc:
|
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)
|
LOGGER.debug("P_ENG(proc): error", exc=exc)
|
||||||
policy_result = PolicyResult(False, str(exc))
|
policy_result = PolicyResult(False, str(exc))
|
||||||
policy_result.source_policy = self.binding.policy
|
policy_result.source_policy = self.binding.policy
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
"""policy process tests"""
|
"""policy process tests"""
|
||||||
from django.core.cache import cache
|
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.core.models import Application, User
|
||||||
from authentik.events.models import Event, EventAction
|
from authentik.events.models import Event, EventAction
|
||||||
|
@ -22,6 +22,7 @@ class TestPolicyProcess(TestCase):
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
clear_policy_cache()
|
clear_policy_cache()
|
||||||
|
self.factory = RequestFactory()
|
||||||
self.user = User.objects.create_user(username="policyuser")
|
self.user = User.objects.create_user(username="policyuser")
|
||||||
|
|
||||||
def test_invalid(self):
|
def test_invalid(self):
|
||||||
|
@ -64,7 +65,9 @@ class TestPolicyProcess(TestCase):
|
||||||
def test_exception(self):
|
def test_exception(self):
|
||||||
"""Test policy execution"""
|
"""Test policy execution"""
|
||||||
policy = Policy.objects.create()
|
policy = Policy.objects.create()
|
||||||
binding = PolicyBinding(policy=policy)
|
binding = PolicyBinding(
|
||||||
|
policy=policy, target=Application.objects.create(name="test")
|
||||||
|
)
|
||||||
|
|
||||||
request = PolicyRequest(self.user)
|
request = PolicyRequest(self.user)
|
||||||
response = PolicyProcess(binding, request, None).execute()
|
response = PolicyProcess(binding, request, None).execute()
|
||||||
|
@ -79,29 +82,47 @@ class TestPolicyProcess(TestCase):
|
||||||
policy=policy, target=Application.objects.create(name="test")
|
policy=policy, target=Application.objects.create(name="test")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
http_request = self.factory.get("/")
|
||||||
|
http_request.user = self.user
|
||||||
|
|
||||||
request = PolicyRequest(self.user)
|
request = PolicyRequest(self.user)
|
||||||
|
request.http_request = http_request
|
||||||
response = PolicyProcess(binding, request, None).execute()
|
response = PolicyProcess(binding, request, None).execute()
|
||||||
self.assertEqual(response.passing, False)
|
self.assertEqual(response.passing, False)
|
||||||
self.assertEqual(response.messages, ("dummy",))
|
self.assertEqual(response.messages, ("dummy",))
|
||||||
|
|
||||||
events = Event.objects.filter(
|
events = Event.objects.filter(
|
||||||
action=EventAction.POLICY_EXECUTION,
|
action=EventAction.POLICY_EXECUTION,
|
||||||
|
context__policy_uuid=policy.policy_uuid.hex,
|
||||||
)
|
)
|
||||||
self.assertTrue(events.exists())
|
self.assertTrue(events.exists())
|
||||||
self.assertEqual(len(events), 1)
|
self.assertEqual(len(events), 1)
|
||||||
event = events.first()
|
event = events.first()
|
||||||
|
self.assertEqual(event.user["username"], self.user.username)
|
||||||
self.assertEqual(event.context["result"]["passing"], False)
|
self.assertEqual(event.context["result"]["passing"], False)
|
||||||
self.assertEqual(event.context["result"]["messages"], ["dummy"])
|
self.assertEqual(event.context["result"]["messages"], ["dummy"])
|
||||||
|
self.assertEqual(event.client_ip, "127.0.0.1")
|
||||||
|
|
||||||
def test_raises(self):
|
def test_raises(self):
|
||||||
"""Test policy that raises error"""
|
"""Test policy that raises error"""
|
||||||
policy_raises = ExpressionPolicy.objects.create(
|
policy_raises = ExpressionPolicy.objects.create(
|
||||||
name="raises", expression="{{ 0/0 }}"
|
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)
|
request = PolicyRequest(self.user)
|
||||||
response = PolicyProcess(binding, request, None).execute()
|
response = PolicyProcess(binding, request, None).execute()
|
||||||
self.assertEqual(response.passing, False)
|
self.assertEqual(response.passing, False)
|
||||||
self.assertEqual(response.messages, ("division by zero",))
|
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"])
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
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.db.models import Model
|
||||||
from django.http import HttpRequest
|
from django.http import HttpRequest
|
||||||
|
@ -19,7 +19,7 @@ class PolicyRequest:
|
||||||
user: User
|
user: User
|
||||||
http_request: Optional[HttpRequest]
|
http_request: Optional[HttpRequest]
|
||||||
obj: Optional[Model]
|
obj: Optional[Model]
|
||||||
context: Dict[str, str]
|
context: dict[str, Any]
|
||||||
|
|
||||||
def __init__(self, user: User):
|
def __init__(self, user: User):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
|
@ -37,10 +37,10 @@ class PolicyResult:
|
||||||
"""Small data-class to hold policy results"""
|
"""Small data-class to hold policy results"""
|
||||||
|
|
||||||
passing: bool
|
passing: bool
|
||||||
messages: Tuple[str, ...]
|
messages: tuple[str, ...]
|
||||||
|
|
||||||
source_policy: Optional[Policy]
|
source_policy: Optional[Policy]
|
||||||
source_results: Optional[List["PolicyResult"]]
|
source_results: Optional[list["PolicyResult"]]
|
||||||
|
|
||||||
def __init__(self, passing: bool, *messages: str):
|
def __init__(self, passing: bool, *messages: str):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
|
|
|
@ -93,11 +93,12 @@ INSTALLED_APPS = [
|
||||||
"authentik.lib.apps.AuthentikLibConfig",
|
"authentik.lib.apps.AuthentikLibConfig",
|
||||||
"authentik.policies.apps.AuthentikPoliciesConfig",
|
"authentik.policies.apps.AuthentikPoliciesConfig",
|
||||||
"authentik.policies.dummy.apps.AuthentikPolicyDummyConfig",
|
"authentik.policies.dummy.apps.AuthentikPolicyDummyConfig",
|
||||||
|
"authentik.policies.event_matcher.apps.AuthentikPoliciesEventMatcherConfig",
|
||||||
"authentik.policies.expiry.apps.AuthentikPolicyExpiryConfig",
|
"authentik.policies.expiry.apps.AuthentikPolicyExpiryConfig",
|
||||||
"authentik.policies.expression.apps.AuthentikPolicyExpressionConfig",
|
"authentik.policies.expression.apps.AuthentikPolicyExpressionConfig",
|
||||||
|
"authentik.policies.group_membership.apps.AuthentikPoliciesGroupMembershipConfig",
|
||||||
"authentik.policies.hibp.apps.AuthentikPolicyHIBPConfig",
|
"authentik.policies.hibp.apps.AuthentikPolicyHIBPConfig",
|
||||||
"authentik.policies.password.apps.AuthentikPoliciesPasswordConfig",
|
"authentik.policies.password.apps.AuthentikPoliciesPasswordConfig",
|
||||||
"authentik.policies.group_membership.apps.AuthentikPoliciesGroupMembershipConfig",
|
|
||||||
"authentik.policies.reputation.apps.AuthentikPolicyReputationConfig",
|
"authentik.policies.reputation.apps.AuthentikPolicyReputationConfig",
|
||||||
"authentik.providers.proxy.apps.AuthentikProviderProxyConfig",
|
"authentik.providers.proxy.apps.AuthentikProviderProxyConfig",
|
||||||
"authentik.providers.oauth2.apps.AuthentikProviderOAuth2Config",
|
"authentik.providers.oauth2.apps.AuthentikProviderOAuth2Config",
|
||||||
|
|
|
@ -30,9 +30,7 @@ class Command(BaseCommand): # pragma: no cover
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
# pyright: reportGeneralTypeIssues=false
|
# pyright: reportGeneralTypeIssues=false
|
||||||
send_mail( # pylint: disable=no-value-for-parameter
|
send_mail(message.__dict__, stage.pk)
|
||||||
stage.pk, message.__dict__
|
|
||||||
)
|
|
||||||
finally:
|
finally:
|
||||||
if delete_stage:
|
if delete_stage:
|
||||||
stage.delete()
|
stage.delete()
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
"""email stage tasks"""
|
"""email stage tasks"""
|
||||||
from email.utils import make_msgid
|
from email.utils import make_msgid
|
||||||
from smtplib import SMTPException
|
from smtplib import SMTPException
|
||||||
from typing import Any, Dict, List
|
from typing import Any, Optional
|
||||||
|
|
||||||
from celery import group
|
from celery import group
|
||||||
from django.core.mail import EmailMultiAlternatives
|
from django.core.mail import EmailMultiAlternatives
|
||||||
|
@ -16,11 +16,11 @@ from authentik.stages.email.models import EmailStage
|
||||||
LOGGER = get_logger()
|
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"""
|
"""Wrapper to convert EmailMessage to dict and send it from worker"""
|
||||||
tasks = []
|
tasks = []
|
||||||
for message in messages:
|
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)
|
lazy_group = group(*tasks)
|
||||||
promise = lazy_group()
|
promise = lazy_group()
|
||||||
return promise
|
return promise
|
||||||
|
@ -35,12 +35,17 @@ def send_mails(stage: EmailStage, *messages: List[EmailMultiAlternatives]):
|
||||||
retry_backoff=True,
|
retry_backoff=True,
|
||||||
base=MonitoredTask,
|
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."""
|
"""Send Email for Email Stage. Retries are scheduled automatically."""
|
||||||
self.save_on_success = False
|
self.save_on_success = False
|
||||||
message_id = make_msgid(domain=DNS_NAME)
|
message_id = make_msgid(domain=DNS_NAME)
|
||||||
self.set_uid(slugify(message_id.replace(".", "_").replace("@", "_")))
|
self.set_uid(slugify(message_id.replace(".", "_").replace("@", "_")))
|
||||||
try:
|
try:
|
||||||
|
if not email_stage_pk:
|
||||||
|
stage: EmailStage = EmailStage()
|
||||||
|
else:
|
||||||
stage: EmailStage = EmailStage.objects.get(pk=email_stage_pk)
|
stage: EmailStage = EmailStage.objects.get(pk=email_stage_pk)
|
||||||
backend = stage.backend
|
backend = stage.backend
|
||||||
backend.open()
|
backend.open()
|
||||||
|
|
|
@ -4,7 +4,7 @@ printf '{"event": "Bootstrap completed", "level": "info", "logger": "bootstrap",
|
||||||
if [[ "$1" == "server" ]]; then
|
if [[ "$1" == "server" ]]; then
|
||||||
gunicorn -c /lifecycle/gunicorn.conf.py authentik.root.asgi:application
|
gunicorn -c /lifecycle/gunicorn.conf.py authentik.root.asgi:application
|
||||||
elif [[ "$1" == "worker" ]]; then
|
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
|
elif [[ "$1" == "migrate" ]]; then
|
||||||
# Run system migrations first, run normal migrations after
|
# Run system migrations first, run normal migrations after
|
||||||
python -m lifecycle.migrate
|
python -m lifecycle.migrate
|
||||||
|
|
723
swagger.yaml
723
swagger.yaml
|
@ -979,6 +979,413 @@ paths:
|
||||||
required: true
|
required: true
|
||||||
type: string
|
type: string
|
||||||
format: uuid
|
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/:
|
/flows/bindings/:
|
||||||
get:
|
get:
|
||||||
operationId: flows_bindings_list
|
operationId: flows_bindings_list
|
||||||
|
@ -2287,6 +2694,133 @@ paths:
|
||||||
required: true
|
required: true
|
||||||
type: string
|
type: string
|
||||||
format: uuid
|
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/:
|
/policies/expression/:
|
||||||
get:
|
get:
|
||||||
operationId: policies_expression_list
|
operationId: policies_expression_list
|
||||||
|
@ -7083,6 +7617,105 @@ definitions:
|
||||||
unique_users:
|
unique_users:
|
||||||
title: Unique users
|
title: Unique users
|
||||||
type: integer
|
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:
|
Stage:
|
||||||
title: Stage obj
|
title: Stage obj
|
||||||
description: Stage Serializer
|
description: Stage Serializer
|
||||||
|
@ -7557,6 +8190,96 @@ definitions:
|
||||||
type: integer
|
type: integer
|
||||||
maximum: 2147483647
|
maximum: 2147483647
|
||||||
minimum: -2147483648
|
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:
|
ExpressionPolicy:
|
||||||
description: Group Membership Policy Serializer
|
description: Group Membership Policy Serializer
|
||||||
required:
|
required:
|
||||||
|
|
Reference in a new issue