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.users import UserViewSet
|
||||
from authentik.crypto.api import CertificateKeyPairViewSet
|
||||
from authentik.events.api import EventViewSet
|
||||
from authentik.events.api.event import EventViewSet
|
||||
from authentik.events.api.notification import NotificationViewSet
|
||||
from authentik.events.api.notification_transport import NotificationTransportViewSet
|
||||
from authentik.events.api.notification_trigger import NotificationTriggerViewSet
|
||||
from authentik.flows.api import (
|
||||
FlowCacheViewSet,
|
||||
FlowStageBindingViewSet,
|
||||
|
@ -37,6 +40,7 @@ from authentik.policies.api import (
|
|||
PolicyViewSet,
|
||||
)
|
||||
from authentik.policies.dummy.api import DummyPolicyViewSet
|
||||
from authentik.policies.event_matcher.api import EventMatcherPolicyViewSet
|
||||
from authentik.policies.expiry.api import PasswordExpiryPolicyViewSet
|
||||
from authentik.policies.expression.api import ExpressionPolicyViewSet
|
||||
from authentik.policies.group_membership.api import GroupMembershipPolicyViewSet
|
||||
|
@ -97,6 +101,9 @@ router.register("flows/bindings", FlowStageBindingViewSet)
|
|||
router.register("crypto/certificatekeypairs", CertificateKeyPairViewSet)
|
||||
|
||||
router.register("events/events", EventViewSet)
|
||||
router.register("events/notifications", NotificationViewSet)
|
||||
router.register("events/transports", NotificationTransportViewSet)
|
||||
router.register("events/triggers", NotificationTriggerViewSet)
|
||||
|
||||
router.register("sources/all", SourceViewSet)
|
||||
router.register("sources/ldap", LDAPSourceViewSet)
|
||||
|
@ -107,6 +114,7 @@ router.register("policies/all", PolicyViewSet)
|
|||
router.register("policies/cached", PolicyCacheViewSet, basename="policies_cache")
|
||||
router.register("policies/bindings", PolicyBindingViewSet)
|
||||
router.register("policies/expression", ExpressionPolicyViewSet)
|
||||
router.register("policies/event_matcher", EventMatcherPolicyViewSet)
|
||||
router.register("policies/group_membership", GroupMembershipPolicyViewSet)
|
||||
router.register("policies/haveibeenpwned", HaveIBeenPwendPolicyViewSet)
|
||||
router.register("policies/password_expiry", PasswordExpiryPolicyViewSet)
|
||||
|
|
|
@ -23,7 +23,7 @@ class PropertyMappingSerializer(ModelSerializer):
|
|||
class PropertyMappingViewSet(ReadOnlyModelViewSet):
|
||||
"""PropertyMapping Viewset"""
|
||||
|
||||
queryset = PropertyMapping.objects.all()
|
||||
queryset = PropertyMapping.objects.none()
|
||||
serializer_class = PropertyMappingSerializer
|
||||
|
||||
def get_queryset(self):
|
||||
|
|
|
@ -39,7 +39,7 @@ class ProviderSerializer(ModelSerializer, MetaNameSerializer):
|
|||
class ProviderViewSet(ModelViewSet):
|
||||
"""Provider Viewset"""
|
||||
|
||||
queryset = Provider.objects.all()
|
||||
queryset = Provider.objects.none()
|
||||
serializer_class = ProviderSerializer
|
||||
filterset_fields = {
|
||||
"application": ["isnull"],
|
||||
|
|
|
@ -31,7 +31,7 @@ class SourceSerializer(ModelSerializer, MetaNameSerializer):
|
|||
class SourceViewSet(ReadOnlyModelViewSet):
|
||||
"""Source Viewset"""
|
||||
|
||||
queryset = Source.objects.all()
|
||||
queryset = Source.objects.none()
|
||||
serializer_class = SourceSerializer
|
||||
lookup_field = "slug"
|
||||
|
||||
|
|
|
@ -34,7 +34,7 @@ class UserSerializer(ModelSerializer):
|
|||
class UserViewSet(ModelViewSet):
|
||||
"""User Viewset"""
|
||||
|
||||
queryset = User.objects.all()
|
||||
queryset = User.objects.none()
|
||||
serializer_class = UserSerializer
|
||||
|
||||
def get_queryset(self):
|
||||
|
|
|
@ -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)
|
|
@ -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))
|
|
@ -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.http import HttpRequest
|
||||
from django.utils.translation import gettext as _
|
||||
from requests import post
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik import __version__
|
||||
from authentik.core.middleware import (
|
||||
SESSION_IMPERSONATE_ORIGINAL_USER,
|
||||
SESSION_IMPERSONATE_USER,
|
||||
)
|
||||
from authentik.core.models import User
|
||||
from authentik.core.models import Group, User
|
||||
from authentik.events.utils import cleanse_dict, get_user, sanitize_dict
|
||||
from authentik.lib.utils.http import get_client_ip
|
||||
from authentik.policies.models import PolicyBindingModel
|
||||
from authentik.stages.email.tasks import send_mail
|
||||
from authentik.stages.email.utils import TemplateEmailMessage
|
||||
|
||||
LOGGER = get_logger("authentik.events")
|
||||
|
||||
|
@ -104,10 +109,12 @@ class Event(models.Model):
|
|||
Events independently from requests.
|
||||
`user` arguments optionally overrides user from requests."""
|
||||
if hasattr(request, "user"):
|
||||
self.user = get_user(
|
||||
request.user,
|
||||
request.session.get(SESSION_IMPERSONATE_ORIGINAL_USER, None),
|
||||
original_user = None
|
||||
if hasattr(request, "session"):
|
||||
original_user = request.session.get(
|
||||
SESSION_IMPERSONATE_ORIGINAL_USER, None
|
||||
)
|
||||
self.user = get_user(request.user, original_user)
|
||||
if user:
|
||||
self.user = get_user(user)
|
||||
# Check if we're currently impersonating, and add that user
|
||||
|
@ -139,7 +146,189 @@ class Event(models.Model):
|
|||
)
|
||||
return super().save(*args, **kwargs)
|
||||
|
||||
@property
|
||||
def summary(self) -> str:
|
||||
"""Return a summary of this event."""
|
||||
if "message" in self.context:
|
||||
return self.context["message"]
|
||||
return f"{self.action}: {self.context}"
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"<Event action={self.action} user={self.user} context={self.context}>"
|
||||
|
||||
class Meta:
|
||||
|
||||
verbose_name = _("Event")
|
||||
verbose_name_plural = _("Events")
|
||||
|
||||
|
||||
class TransportMode(models.TextChoices):
|
||||
"""Modes that a notification transport can send a notification"""
|
||||
|
||||
WEBHOOK = "webhook", _("Generic Webhook")
|
||||
WEBHOOK_SLACK = "webhook_slack", _("Slack Webhook (Slack/Discord)")
|
||||
EMAIL = "email", _("Email")
|
||||
|
||||
|
||||
class NotificationTransport(models.Model):
|
||||
"""Action which is executed when a Trigger matches"""
|
||||
|
||||
uuid = models.UUIDField(primary_key=True, editable=False, default=uuid4)
|
||||
|
||||
name = models.TextField(unique=True)
|
||||
mode = models.TextField(choices=TransportMode.choices)
|
||||
|
||||
webhook_url = models.TextField(blank=True)
|
||||
|
||||
def send(self, notification: "Notification") -> list[str]:
|
||||
"""Send notification to user, called from async task"""
|
||||
if self.mode == TransportMode.WEBHOOK:
|
||||
return self.send_webhook(notification)
|
||||
if self.mode == TransportMode.WEBHOOK_SLACK:
|
||||
return self.send_webhook_slack(notification)
|
||||
if self.mode == TransportMode.EMAIL:
|
||||
return self.send_email(notification)
|
||||
raise ValueError(f"Invalid mode {self.mode} set")
|
||||
|
||||
def send_webhook(self, notification: "Notification") -> list[str]:
|
||||
"""Send notification to generic webhook"""
|
||||
response = post(
|
||||
self.webhook_url,
|
||||
json={
|
||||
"body": notification.body,
|
||||
"severity": notification.severity,
|
||||
},
|
||||
)
|
||||
return [
|
||||
response.status_code,
|
||||
response.text,
|
||||
]
|
||||
|
||||
def send_webhook_slack(self, notification: "Notification") -> list[str]:
|
||||
"""Send notification to slack or slack-compatible endpoints"""
|
||||
body = {
|
||||
"username": "authentik",
|
||||
"icon_url": "https://goauthentik.io/img/icon.png",
|
||||
"attachments": [
|
||||
{
|
||||
"author_name": "authentik",
|
||||
"author_link": "https://goauthentik.io",
|
||||
"author_icon": "https://goauthentik.io/img/icon.png",
|
||||
"title": notification.body,
|
||||
"color": "#fd4b2d",
|
||||
"fields": [
|
||||
{
|
||||
"title": _("Severity"),
|
||||
"value": notification.severity,
|
||||
"short": True,
|
||||
},
|
||||
{
|
||||
"title": _("Dispatched for user"),
|
||||
"value": str(notification.user),
|
||||
"short": True,
|
||||
},
|
||||
],
|
||||
"footer": f"authentik v{__version__}",
|
||||
}
|
||||
],
|
||||
}
|
||||
if notification.event:
|
||||
body["attachments"][0]["title"] = notification.event.action
|
||||
body["attachments"][0]["text"] = notification.event.action
|
||||
response = post(self.webhook_url, json=body)
|
||||
return [
|
||||
response.status_code,
|
||||
response.text,
|
||||
]
|
||||
|
||||
def send_email(self, notification: "Notification") -> list[str]:
|
||||
"""Send notification via global email configuration"""
|
||||
body_trunc = (
|
||||
(notification.body[:75] + "..")
|
||||
if len(notification.body) > 75
|
||||
else notification.body
|
||||
)
|
||||
mail = TemplateEmailMessage(
|
||||
subject=f"authentik Notification: {body_trunc}",
|
||||
template_name="email/setup.html",
|
||||
to=[notification.user.email],
|
||||
template_context={
|
||||
"body": notification.body,
|
||||
},
|
||||
)
|
||||
# Email is sent directly here, as the call to send() should have been from a task.
|
||||
# pyright: reportGeneralTypeIssues=false
|
||||
return send_mail(mail.__dict__) # pylint: disable=no-value-for-parameter
|
||||
|
||||
class Meta:
|
||||
|
||||
verbose_name = _("Notification Transport")
|
||||
verbose_name_plural = _("Notification Transports")
|
||||
|
||||
|
||||
class NotificationSeverity(models.TextChoices):
|
||||
"""Severity images that a notification can have"""
|
||||
|
||||
NOTICE = "notice", _("Notice")
|
||||
WARNING = "warning", _("Warning")
|
||||
ALERT = "alert", _("Alert")
|
||||
|
||||
|
||||
class Notification(models.Model):
|
||||
"""Event Notification"""
|
||||
|
||||
uuid = models.UUIDField(primary_key=True, editable=False, default=uuid4)
|
||||
severity = models.TextField(choices=NotificationSeverity.choices)
|
||||
body = models.TextField()
|
||||
created = models.DateTimeField(auto_now_add=True)
|
||||
event = models.ForeignKey(Event, on_delete=models.SET_NULL, null=True, blank=True)
|
||||
seen = models.BooleanField(default=False)
|
||||
user = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||
|
||||
def __str__(self) -> str:
|
||||
body_trunc = (self.body[:75] + "..") if len(self.body) > 75 else self.body
|
||||
return f"Notification for user {self.user}: {body_trunc}"
|
||||
|
||||
class Meta:
|
||||
|
||||
verbose_name = _("Notification")
|
||||
verbose_name_plural = _("Notifications")
|
||||
|
||||
|
||||
class NotificationTrigger(PolicyBindingModel):
|
||||
"""Decide when to create a Notification based on policies attached to this object."""
|
||||
|
||||
name = models.TextField(unique=True)
|
||||
transports = models.ManyToManyField(
|
||||
NotificationTransport,
|
||||
help_text=_(
|
||||
(
|
||||
"Select which transports should be used to notify the user. If none are "
|
||||
"selected, the notification will only be shown in the authentik UI."
|
||||
)
|
||||
),
|
||||
)
|
||||
severity = models.TextField(
|
||||
choices=NotificationSeverity.choices,
|
||||
default=NotificationSeverity.NOTICE,
|
||||
help_text=_(
|
||||
"Controls which severity level the created notifications will have."
|
||||
),
|
||||
)
|
||||
group = models.ForeignKey(
|
||||
Group,
|
||||
help_text=_(
|
||||
(
|
||||
"Define which group of users this notification should be sent and shown to. "
|
||||
"If left empty, Notification won't ben sent."
|
||||
)
|
||||
),
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
|
||||
verbose_name = _("Notification Trigger")
|
||||
verbose_name_plural = _("Notification Triggers")
|
||||
|
|
|
@ -7,12 +7,14 @@ from django.contrib.auth.signals import (
|
|||
user_logged_out,
|
||||
user_login_failed,
|
||||
)
|
||||
from django.db.models.signals import post_save
|
||||
from django.dispatch import receiver
|
||||
from django.http import HttpRequest
|
||||
|
||||
from authentik.core.models import User
|
||||
from authentik.core.signals import password_changed
|
||||
from authentik.events.models import Event, EventAction
|
||||
from authentik.events.tasks import event_notification_handler
|
||||
from authentik.stages.invitation.models import Invitation
|
||||
from authentik.stages.invitation.signals import invitation_used
|
||||
from authentik.stages.user_write.signals import user_write
|
||||
|
@ -95,3 +97,10 @@ def on_password_changed(sender, user: User, password: str, **_):
|
|||
"""Log password change"""
|
||||
thread = EventNewThread(EventAction.PASSWORD_SET, None, user=user)
|
||||
thread.run()
|
||||
|
||||
|
||||
@receiver(post_save, sender=Event)
|
||||
# pylint: disable=unused-argument
|
||||
def event_post_save_notification(sender, instance: Event, **_):
|
||||
"""Start task to check if any policies trigger an notification on this event"""
|
||||
event_notification_handler.delay(instance.event_uuid.hex)
|
||||
|
|
|
@ -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
|
|
@ -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.test import TestCase
|
||||
from guardian.shortcuts import get_anonymous_user
|
||||
|
||||
from authentik.core.models import Group
|
||||
from authentik.events.models import Event
|
||||
from authentik.policies.dummy.models import DummyPolicy
|
||||
|
||||
|
@ -13,14 +14,24 @@ class TestEvents(TestCase):
|
|||
|
||||
def test_new_with_model(self):
|
||||
"""Create a new Event passing a model as kwarg"""
|
||||
event = Event.new("unittest", test={"model": get_anonymous_user()})
|
||||
test_model = Group.objects.create(name="test")
|
||||
event = Event.new("unittest", test={"model": test_model})
|
||||
event.save() # We save to ensure nothing is un-saveable
|
||||
model_content_type = ContentType.objects.get_for_model(get_anonymous_user())
|
||||
model_content_type = ContentType.objects.get_for_model(test_model)
|
||||
self.assertEqual(
|
||||
event.context.get("test").get("model").get("app"),
|
||||
model_content_type.app_label,
|
||||
)
|
||||
|
||||
def test_new_with_user(self):
|
||||
"""Create a new Event passing a user as kwarg"""
|
||||
event = Event.new("unittest", test={"model": get_anonymous_user()})
|
||||
event.save() # We save to ensure nothing is un-saveable
|
||||
self.assertEqual(
|
||||
event.context.get("test").get("model").get("username"),
|
||||
get_anonymous_user().username,
|
||||
)
|
||||
|
||||
def test_new_with_uuid_model(self):
|
||||
"""Create a new Event passing a model (with UUID PK) as kwarg"""
|
||||
temp_model = DummyPolicy.objects.create(name="test", result=True)
|
||||
|
|
|
@ -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()
|
||||
)
|
|
@ -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 django.contrib.auth.models import AnonymousUser
|
||||
from django.core.handlers.wsgi import WSGIRequest
|
||||
from django.db import models
|
||||
from django.db.models.base import Model
|
||||
from django.http.request import HttpRequest
|
||||
from django.views.debug import SafeExceptionReporterFilter
|
||||
from guardian.utils import get_anonymous_user
|
||||
|
||||
|
@ -83,10 +85,14 @@ def sanitize_dict(source: Dict[Any, Any]) -> Dict[Any, Any]:
|
|||
value = asdict(value)
|
||||
if isinstance(value, dict):
|
||||
final_dict[key] = sanitize_dict(value)
|
||||
elif isinstance(value, User):
|
||||
final_dict[key] = sanitize_dict(get_user(value))
|
||||
elif isinstance(value, models.Model):
|
||||
final_dict[key] = sanitize_dict(model_to_dict(value))
|
||||
elif isinstance(value, UUID):
|
||||
final_dict[key] = value.hex
|
||||
elif isinstance(value, (HttpRequest, WSGIRequest)):
|
||||
continue
|
||||
else:
|
||||
final_dict[key] = value
|
||||
return final_dict
|
||||
|
|
|
@ -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
|
|
@ -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"
|
|
@ -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(),
|
||||
}
|
|
@ -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)",
|
||||
),
|
||||
),
|
||||
]
|
|
@ -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")
|
|
@ -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"""
|
||||
from ipaddress import ip_address, ip_network
|
||||
from traceback import format_tb
|
||||
from typing import TYPE_CHECKING, List, Optional
|
||||
|
||||
from django.http import HttpRequest
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.events.models import Event, EventAction
|
||||
from authentik.events.utils import model_to_dict, sanitize_dict
|
||||
from authentik.flows.planner import PLAN_CONTEXT_SSO
|
||||
from authentik.lib.expression.evaluator import BaseEvaluator
|
||||
from authentik.lib.utils.http import get_client_ip
|
||||
from authentik.policies.exceptions import PolicyException
|
||||
from authentik.policies.types import PolicyRequest, PolicyResult
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
@ -57,32 +55,22 @@ class PolicyEvaluator(BaseEvaluator):
|
|||
|
||||
def handle_error(self, exc: Exception, expression_source: str):
|
||||
"""Exception Handler"""
|
||||
error_string = "\n".join(format_tb(exc.__traceback__) + [str(exc)])
|
||||
event = Event.new(
|
||||
EventAction.POLICY_EXCEPTION,
|
||||
expression=expression_source,
|
||||
error=error_string,
|
||||
request=self._context["request"],
|
||||
)
|
||||
if self.policy:
|
||||
event.context["model"] = sanitize_dict(model_to_dict(self.policy))
|
||||
if "http_request" in self._context:
|
||||
event.from_http(self._context["http_request"])
|
||||
else:
|
||||
event.set_user(self._context["request"].user)
|
||||
event.save()
|
||||
raise PolicyException(str(exc)) from exc
|
||||
|
||||
def evaluate(self, expression_source: str) -> PolicyResult:
|
||||
"""Parse and evaluate expression. Policy is expected to return a truthy object.
|
||||
Messages can be added using 'do ak_message()'."""
|
||||
try:
|
||||
result = super().evaluate(expression_source)
|
||||
except PolicyException as exc:
|
||||
# PolicyExceptions should be propagated back to the process,
|
||||
# which handles recording and returning a correct result
|
||||
raise exc
|
||||
except Exception as exc: # pylint: disable=broad-except
|
||||
LOGGER.warning("Expression error", exc=exc)
|
||||
return PolicyResult(False, str(exc))
|
||||
else:
|
||||
policy_result = PolicyResult(False)
|
||||
policy_result.messages = tuple(self._messages)
|
||||
policy_result = PolicyResult(False, *self._messages)
|
||||
if result is None:
|
||||
LOGGER.warning(
|
||||
"Expression policy returned None",
|
||||
|
|
|
@ -3,7 +3,7 @@ from django.core.exceptions import ValidationError
|
|||
from django.test import TestCase
|
||||
from guardian.shortcuts import get_anonymous_user
|
||||
|
||||
from authentik.events.models import Event, EventAction
|
||||
from authentik.policies.exceptions import PolicyException
|
||||
from authentik.policies.expression.evaluator import PolicyEvaluator
|
||||
from authentik.policies.expression.models import ExpressionPolicy
|
||||
from authentik.policies.types import PolicyRequest
|
||||
|
@ -44,30 +44,8 @@ class TestEvaluator(TestCase):
|
|||
template = ";"
|
||||
evaluator = PolicyEvaluator("test")
|
||||
evaluator.set_policy_request(self.request)
|
||||
result = evaluator.evaluate(template)
|
||||
self.assertEqual(result.passing, False)
|
||||
self.assertEqual(result.messages, ("invalid syntax (test, line 3)",))
|
||||
self.assertTrue(
|
||||
Event.objects.filter(
|
||||
action=EventAction.POLICY_EXCEPTION,
|
||||
context__expression=template,
|
||||
).exists()
|
||||
)
|
||||
|
||||
def test_undefined(self):
|
||||
"""test undefined result"""
|
||||
template = "{{ foo.bar }}"
|
||||
evaluator = PolicyEvaluator("test")
|
||||
evaluator.set_policy_request(self.request)
|
||||
result = evaluator.evaluate(template)
|
||||
self.assertEqual(result.passing, False)
|
||||
self.assertEqual(result.messages, ("name 'foo' is not defined",))
|
||||
self.assertTrue(
|
||||
Event.objects.filter(
|
||||
action=EventAction.POLICY_EXCEPTION,
|
||||
context__expression=template,
|
||||
).exists()
|
||||
)
|
||||
with self.assertRaises(PolicyException):
|
||||
evaluator.evaluate(template)
|
||||
|
||||
def test_validate(self):
|
||||
"""test validate"""
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
"""authentik policy task"""
|
||||
from multiprocessing import Process
|
||||
from multiprocessing.connection import Connection
|
||||
from traceback import format_tb
|
||||
from typing import Optional
|
||||
|
||||
from django.core.cache import cache
|
||||
|
@ -19,7 +20,7 @@ LOGGER = get_logger()
|
|||
def cache_key(binding: PolicyBinding, request: PolicyRequest) -> str:
|
||||
"""Generate Cache key for policy"""
|
||||
prefix = f"policy_{binding.policy_binding_uuid.hex}_{binding.policy.pk.hex}"
|
||||
if request.http_request:
|
||||
if request.http_request and hasattr(request.http_request, "session"):
|
||||
prefix += f"_{request.http_request.session.session_key}"
|
||||
if request.user:
|
||||
prefix += f"#{request.user.pk}"
|
||||
|
@ -47,6 +48,23 @@ class PolicyProcess(Process):
|
|||
if connection:
|
||||
self.connection = connection
|
||||
|
||||
def create_event(self, action: str, **kwargs):
|
||||
"""Create event with common values from `self.request` and `self.binding`."""
|
||||
# Keep a reference to http_request even if its None, because cleanse_dict will remove it
|
||||
http_request = self.request.http_request
|
||||
event = Event.new(
|
||||
action=action,
|
||||
policy_uuid=self.binding.policy.policy_uuid.hex,
|
||||
binding=self.binding,
|
||||
request=self.request,
|
||||
**kwargs,
|
||||
)
|
||||
event.set_user(self.request.user)
|
||||
if http_request:
|
||||
event.from_http(http_request)
|
||||
else:
|
||||
event.save()
|
||||
|
||||
def execute(self) -> PolicyResult:
|
||||
"""Run actual policy, returns result"""
|
||||
LOGGER.debug(
|
||||
|
@ -58,15 +76,11 @@ class PolicyProcess(Process):
|
|||
try:
|
||||
policy_result = self.binding.policy.passes(self.request)
|
||||
if self.binding.policy.execution_logging:
|
||||
event = Event.new(
|
||||
EventAction.POLICY_EXECUTION,
|
||||
request=self.request,
|
||||
binding=self.binding,
|
||||
result=policy_result,
|
||||
)
|
||||
event.set_user(self.request.user)
|
||||
event.save()
|
||||
self.create_event(EventAction.POLICY_EXECUTION, result=policy_result)
|
||||
except PolicyException as exc:
|
||||
# Create policy exception event
|
||||
error_string = "\n".join(format_tb(exc.__traceback__) + [str(exc)])
|
||||
self.create_event(EventAction.POLICY_EXCEPTION, error=error_string)
|
||||
LOGGER.debug("P_ENG(proc): error", exc=exc)
|
||||
policy_result = PolicyResult(False, str(exc))
|
||||
policy_result.source_policy = self.binding.policy
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
"""policy process tests"""
|
||||
from django.core.cache import cache
|
||||
from django.test import TestCase
|
||||
from django.test import RequestFactory, TestCase
|
||||
|
||||
from authentik.core.models import Application, User
|
||||
from authentik.events.models import Event, EventAction
|
||||
|
@ -22,6 +22,7 @@ class TestPolicyProcess(TestCase):
|
|||
|
||||
def setUp(self):
|
||||
clear_policy_cache()
|
||||
self.factory = RequestFactory()
|
||||
self.user = User.objects.create_user(username="policyuser")
|
||||
|
||||
def test_invalid(self):
|
||||
|
@ -64,7 +65,9 @@ class TestPolicyProcess(TestCase):
|
|||
def test_exception(self):
|
||||
"""Test policy execution"""
|
||||
policy = Policy.objects.create()
|
||||
binding = PolicyBinding(policy=policy)
|
||||
binding = PolicyBinding(
|
||||
policy=policy, target=Application.objects.create(name="test")
|
||||
)
|
||||
|
||||
request = PolicyRequest(self.user)
|
||||
response = PolicyProcess(binding, request, None).execute()
|
||||
|
@ -79,29 +82,47 @@ class TestPolicyProcess(TestCase):
|
|||
policy=policy, target=Application.objects.create(name="test")
|
||||
)
|
||||
|
||||
http_request = self.factory.get("/")
|
||||
http_request.user = self.user
|
||||
|
||||
request = PolicyRequest(self.user)
|
||||
request.http_request = http_request
|
||||
response = PolicyProcess(binding, request, None).execute()
|
||||
self.assertEqual(response.passing, False)
|
||||
self.assertEqual(response.messages, ("dummy",))
|
||||
|
||||
events = Event.objects.filter(
|
||||
action=EventAction.POLICY_EXECUTION,
|
||||
context__policy_uuid=policy.policy_uuid.hex,
|
||||
)
|
||||
self.assertTrue(events.exists())
|
||||
self.assertEqual(len(events), 1)
|
||||
event = events.first()
|
||||
self.assertEqual(event.user["username"], self.user.username)
|
||||
self.assertEqual(event.context["result"]["passing"], False)
|
||||
self.assertEqual(event.context["result"]["messages"], ["dummy"])
|
||||
self.assertEqual(event.client_ip, "127.0.0.1")
|
||||
|
||||
def test_raises(self):
|
||||
"""Test policy that raises error"""
|
||||
policy_raises = ExpressionPolicy.objects.create(
|
||||
name="raises", expression="{{ 0/0 }}"
|
||||
)
|
||||
binding = PolicyBinding(policy=policy_raises)
|
||||
binding = PolicyBinding(
|
||||
policy=policy_raises, target=Application.objects.create(name="test")
|
||||
)
|
||||
|
||||
request = PolicyRequest(self.user)
|
||||
response = PolicyProcess(binding, request, None).execute()
|
||||
self.assertEqual(response.passing, False)
|
||||
self.assertEqual(response.messages, ("division by zero",))
|
||||
# self.assert
|
||||
|
||||
events = Event.objects.filter(
|
||||
action=EventAction.POLICY_EXCEPTION,
|
||||
context__policy_uuid=policy_raises.policy_uuid.hex,
|
||||
)
|
||||
self.assertTrue(events.exists())
|
||||
self.assertEqual(len(events), 1)
|
||||
event = events.first()
|
||||
self.assertEqual(event.user["username"], self.user.username)
|
||||
self.assertIn("division by zero", event.context["error"])
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Dict, List, Optional, Tuple
|
||||
from typing import TYPE_CHECKING, Any, Optional
|
||||
|
||||
from django.db.models import Model
|
||||
from django.http import HttpRequest
|
||||
|
@ -19,7 +19,7 @@ class PolicyRequest:
|
|||
user: User
|
||||
http_request: Optional[HttpRequest]
|
||||
obj: Optional[Model]
|
||||
context: Dict[str, str]
|
||||
context: dict[str, Any]
|
||||
|
||||
def __init__(self, user: User):
|
||||
super().__init__()
|
||||
|
@ -37,10 +37,10 @@ class PolicyResult:
|
|||
"""Small data-class to hold policy results"""
|
||||
|
||||
passing: bool
|
||||
messages: Tuple[str, ...]
|
||||
messages: tuple[str, ...]
|
||||
|
||||
source_policy: Optional[Policy]
|
||||
source_results: Optional[List["PolicyResult"]]
|
||||
source_results: Optional[list["PolicyResult"]]
|
||||
|
||||
def __init__(self, passing: bool, *messages: str):
|
||||
super().__init__()
|
||||
|
|
|
@ -93,11 +93,12 @@ INSTALLED_APPS = [
|
|||
"authentik.lib.apps.AuthentikLibConfig",
|
||||
"authentik.policies.apps.AuthentikPoliciesConfig",
|
||||
"authentik.policies.dummy.apps.AuthentikPolicyDummyConfig",
|
||||
"authentik.policies.event_matcher.apps.AuthentikPoliciesEventMatcherConfig",
|
||||
"authentik.policies.expiry.apps.AuthentikPolicyExpiryConfig",
|
||||
"authentik.policies.expression.apps.AuthentikPolicyExpressionConfig",
|
||||
"authentik.policies.group_membership.apps.AuthentikPoliciesGroupMembershipConfig",
|
||||
"authentik.policies.hibp.apps.AuthentikPolicyHIBPConfig",
|
||||
"authentik.policies.password.apps.AuthentikPoliciesPasswordConfig",
|
||||
"authentik.policies.group_membership.apps.AuthentikPoliciesGroupMembershipConfig",
|
||||
"authentik.policies.reputation.apps.AuthentikPolicyReputationConfig",
|
||||
"authentik.providers.proxy.apps.AuthentikProviderProxyConfig",
|
||||
"authentik.providers.oauth2.apps.AuthentikProviderOAuth2Config",
|
||||
|
|
|
@ -30,9 +30,7 @@ class Command(BaseCommand): # pragma: no cover
|
|||
)
|
||||
try:
|
||||
# pyright: reportGeneralTypeIssues=false
|
||||
send_mail( # pylint: disable=no-value-for-parameter
|
||||
stage.pk, message.__dict__
|
||||
)
|
||||
send_mail(message.__dict__, stage.pk)
|
||||
finally:
|
||||
if delete_stage:
|
||||
stage.delete()
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
"""email stage tasks"""
|
||||
from email.utils import make_msgid
|
||||
from smtplib import SMTPException
|
||||
from typing import Any, Dict, List
|
||||
from typing import Any, Optional
|
||||
|
||||
from celery import group
|
||||
from django.core.mail import EmailMultiAlternatives
|
||||
|
@ -16,11 +16,11 @@ from authentik.stages.email.models import EmailStage
|
|||
LOGGER = get_logger()
|
||||
|
||||
|
||||
def send_mails(stage: EmailStage, *messages: List[EmailMultiAlternatives]):
|
||||
def send_mails(stage: EmailStage, *messages: list[EmailMultiAlternatives]):
|
||||
"""Wrapper to convert EmailMessage to dict and send it from worker"""
|
||||
tasks = []
|
||||
for message in messages:
|
||||
tasks.append(send_mail.s(stage.pk, message.__dict__))
|
||||
tasks.append(send_mail.s(message.__dict__, stage.pk))
|
||||
lazy_group = group(*tasks)
|
||||
promise = lazy_group()
|
||||
return promise
|
||||
|
@ -35,12 +35,17 @@ def send_mails(stage: EmailStage, *messages: List[EmailMultiAlternatives]):
|
|||
retry_backoff=True,
|
||||
base=MonitoredTask,
|
||||
)
|
||||
def send_mail(self: MonitoredTask, email_stage_pk: int, message: Dict[Any, Any]):
|
||||
def send_mail(
|
||||
self: MonitoredTask, message: dict[Any, Any], email_stage_pk: Optional[int] = None
|
||||
):
|
||||
"""Send Email for Email Stage. Retries are scheduled automatically."""
|
||||
self.save_on_success = False
|
||||
message_id = make_msgid(domain=DNS_NAME)
|
||||
self.set_uid(slugify(message_id.replace(".", "_").replace("@", "_")))
|
||||
try:
|
||||
if not email_stage_pk:
|
||||
stage: EmailStage = EmailStage()
|
||||
else:
|
||||
stage: EmailStage = EmailStage.objects.get(pk=email_stage_pk)
|
||||
backend = stage.backend
|
||||
backend.open()
|
||||
|
|
|
@ -4,7 +4,7 @@ printf '{"event": "Bootstrap completed", "level": "info", "logger": "bootstrap",
|
|||
if [[ "$1" == "server" ]]; then
|
||||
gunicorn -c /lifecycle/gunicorn.conf.py authentik.root.asgi:application
|
||||
elif [[ "$1" == "worker" ]]; then
|
||||
celery -A authentik.root.celery worker --autoscale 3,1 -E -B -s /tmp/celerybeat-schedule -Q authentik,authentik_scheduled
|
||||
celery -A authentik.root.celery worker --autoscale 3,1 -E -B -s /tmp/celerybeat-schedule -Q authentik,authentik_scheduled,authentik_events
|
||||
elif [[ "$1" == "migrate" ]]; then
|
||||
# Run system migrations first, run normal migrations after
|
||||
python -m lifecycle.migrate
|
||||
|
|
723
swagger.yaml
723
swagger.yaml
|
@ -979,6 +979,413 @@ paths:
|
|||
required: true
|
||||
type: string
|
||||
format: uuid
|
||||
/events/notifications/:
|
||||
get:
|
||||
operationId: events_notifications_list
|
||||
description: Notification Viewset
|
||||
parameters:
|
||||
- name: ordering
|
||||
in: query
|
||||
description: Which field to use when ordering the results.
|
||||
required: false
|
||||
type: string
|
||||
- name: search
|
||||
in: query
|
||||
description: A search term.
|
||||
required: false
|
||||
type: string
|
||||
- name: page
|
||||
in: query
|
||||
description: A page number within the paginated result set.
|
||||
required: false
|
||||
type: integer
|
||||
- name: page_size
|
||||
in: query
|
||||
description: Number of results to return per page.
|
||||
required: false
|
||||
type: integer
|
||||
responses:
|
||||
'200':
|
||||
description: ''
|
||||
schema:
|
||||
required:
|
||||
- count
|
||||
- results
|
||||
type: object
|
||||
properties:
|
||||
count:
|
||||
type: integer
|
||||
next:
|
||||
type: string
|
||||
format: uri
|
||||
x-nullable: true
|
||||
previous:
|
||||
type: string
|
||||
format: uri
|
||||
x-nullable: true
|
||||
results:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/definitions/Notification'
|
||||
tags:
|
||||
- events
|
||||
post:
|
||||
operationId: events_notifications_create
|
||||
description: Notification Viewset
|
||||
parameters:
|
||||
- name: data
|
||||
in: body
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/Notification'
|
||||
responses:
|
||||
'201':
|
||||
description: ''
|
||||
schema:
|
||||
$ref: '#/definitions/Notification'
|
||||
tags:
|
||||
- events
|
||||
parameters: []
|
||||
/events/notifications/{uuid}/:
|
||||
get:
|
||||
operationId: events_notifications_read
|
||||
description: Notification Viewset
|
||||
parameters: []
|
||||
responses:
|
||||
'200':
|
||||
description: ''
|
||||
schema:
|
||||
$ref: '#/definitions/Notification'
|
||||
tags:
|
||||
- events
|
||||
put:
|
||||
operationId: events_notifications_update
|
||||
description: Notification Viewset
|
||||
parameters:
|
||||
- name: data
|
||||
in: body
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/Notification'
|
||||
responses:
|
||||
'200':
|
||||
description: ''
|
||||
schema:
|
||||
$ref: '#/definitions/Notification'
|
||||
tags:
|
||||
- events
|
||||
patch:
|
||||
operationId: events_notifications_partial_update
|
||||
description: Notification Viewset
|
||||
parameters:
|
||||
- name: data
|
||||
in: body
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/Notification'
|
||||
responses:
|
||||
'200':
|
||||
description: ''
|
||||
schema:
|
||||
$ref: '#/definitions/Notification'
|
||||
tags:
|
||||
- events
|
||||
delete:
|
||||
operationId: events_notifications_delete
|
||||
description: Notification Viewset
|
||||
parameters: []
|
||||
responses:
|
||||
'204':
|
||||
description: ''
|
||||
tags:
|
||||
- events
|
||||
parameters:
|
||||
- name: uuid
|
||||
in: path
|
||||
description: A UUID string identifying this Notification.
|
||||
required: true
|
||||
type: string
|
||||
format: uuid
|
||||
/events/transports/:
|
||||
get:
|
||||
operationId: events_transports_list
|
||||
description: NotificationTransport Viewset
|
||||
parameters:
|
||||
- name: ordering
|
||||
in: query
|
||||
description: Which field to use when ordering the results.
|
||||
required: false
|
||||
type: string
|
||||
- name: search
|
||||
in: query
|
||||
description: A search term.
|
||||
required: false
|
||||
type: string
|
||||
- name: page
|
||||
in: query
|
||||
description: A page number within the paginated result set.
|
||||
required: false
|
||||
type: integer
|
||||
- name: page_size
|
||||
in: query
|
||||
description: Number of results to return per page.
|
||||
required: false
|
||||
type: integer
|
||||
responses:
|
||||
'200':
|
||||
description: ''
|
||||
schema:
|
||||
required:
|
||||
- count
|
||||
- results
|
||||
type: object
|
||||
properties:
|
||||
count:
|
||||
type: integer
|
||||
next:
|
||||
type: string
|
||||
format: uri
|
||||
x-nullable: true
|
||||
previous:
|
||||
type: string
|
||||
format: uri
|
||||
x-nullable: true
|
||||
results:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/definitions/NotificationTransport'
|
||||
tags:
|
||||
- events
|
||||
post:
|
||||
operationId: events_transports_create
|
||||
description: NotificationTransport Viewset
|
||||
parameters:
|
||||
- name: data
|
||||
in: body
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/NotificationTransport'
|
||||
responses:
|
||||
'201':
|
||||
description: ''
|
||||
schema:
|
||||
$ref: '#/definitions/NotificationTransport'
|
||||
tags:
|
||||
- events
|
||||
parameters: []
|
||||
/events/transports/{uuid}/:
|
||||
get:
|
||||
operationId: events_transports_read
|
||||
description: NotificationTransport Viewset
|
||||
parameters: []
|
||||
responses:
|
||||
'200':
|
||||
description: ''
|
||||
schema:
|
||||
$ref: '#/definitions/NotificationTransport'
|
||||
tags:
|
||||
- events
|
||||
put:
|
||||
operationId: events_transports_update
|
||||
description: NotificationTransport Viewset
|
||||
parameters:
|
||||
- name: data
|
||||
in: body
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/NotificationTransport'
|
||||
responses:
|
||||
'200':
|
||||
description: ''
|
||||
schema:
|
||||
$ref: '#/definitions/NotificationTransport'
|
||||
tags:
|
||||
- events
|
||||
patch:
|
||||
operationId: events_transports_partial_update
|
||||
description: NotificationTransport Viewset
|
||||
parameters:
|
||||
- name: data
|
||||
in: body
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/NotificationTransport'
|
||||
responses:
|
||||
'200':
|
||||
description: ''
|
||||
schema:
|
||||
$ref: '#/definitions/NotificationTransport'
|
||||
tags:
|
||||
- events
|
||||
delete:
|
||||
operationId: events_transports_delete
|
||||
description: NotificationTransport Viewset
|
||||
parameters: []
|
||||
responses:
|
||||
'204':
|
||||
description: ''
|
||||
tags:
|
||||
- events
|
||||
parameters:
|
||||
- name: uuid
|
||||
in: path
|
||||
description: A UUID string identifying this Notification Transport.
|
||||
required: true
|
||||
type: string
|
||||
format: uuid
|
||||
/events/transports/{uuid}/test/:
|
||||
post:
|
||||
operationId: events_transports_test
|
||||
description: |-
|
||||
Send example notification using selected transport. Requires
|
||||
Modify permissions.
|
||||
parameters:
|
||||
- name: data
|
||||
in: body
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/NotificationTransport'
|
||||
responses:
|
||||
'201':
|
||||
description: ''
|
||||
schema:
|
||||
$ref: '#/definitions/NotificationTransport'
|
||||
tags:
|
||||
- events
|
||||
parameters:
|
||||
- name: uuid
|
||||
in: path
|
||||
description: A UUID string identifying this Notification Transport.
|
||||
required: true
|
||||
type: string
|
||||
format: uuid
|
||||
/events/triggers/:
|
||||
get:
|
||||
operationId: events_triggers_list
|
||||
description: NotificationTrigger Viewset
|
||||
parameters:
|
||||
- name: ordering
|
||||
in: query
|
||||
description: Which field to use when ordering the results.
|
||||
required: false
|
||||
type: string
|
||||
- name: search
|
||||
in: query
|
||||
description: A search term.
|
||||
required: false
|
||||
type: string
|
||||
- name: page
|
||||
in: query
|
||||
description: A page number within the paginated result set.
|
||||
required: false
|
||||
type: integer
|
||||
- name: page_size
|
||||
in: query
|
||||
description: Number of results to return per page.
|
||||
required: false
|
||||
type: integer
|
||||
responses:
|
||||
'200':
|
||||
description: ''
|
||||
schema:
|
||||
required:
|
||||
- count
|
||||
- results
|
||||
type: object
|
||||
properties:
|
||||
count:
|
||||
type: integer
|
||||
next:
|
||||
type: string
|
||||
format: uri
|
||||
x-nullable: true
|
||||
previous:
|
||||
type: string
|
||||
format: uri
|
||||
x-nullable: true
|
||||
results:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/definitions/NotificationTrigger'
|
||||
tags:
|
||||
- events
|
||||
post:
|
||||
operationId: events_triggers_create
|
||||
description: NotificationTrigger Viewset
|
||||
parameters:
|
||||
- name: data
|
||||
in: body
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/NotificationTrigger'
|
||||
responses:
|
||||
'201':
|
||||
description: ''
|
||||
schema:
|
||||
$ref: '#/definitions/NotificationTrigger'
|
||||
tags:
|
||||
- events
|
||||
parameters: []
|
||||
/events/triggers/{pbm_uuid}/:
|
||||
get:
|
||||
operationId: events_triggers_read
|
||||
description: NotificationTrigger Viewset
|
||||
parameters: []
|
||||
responses:
|
||||
'200':
|
||||
description: ''
|
||||
schema:
|
||||
$ref: '#/definitions/NotificationTrigger'
|
||||
tags:
|
||||
- events
|
||||
put:
|
||||
operationId: events_triggers_update
|
||||
description: NotificationTrigger Viewset
|
||||
parameters:
|
||||
- name: data
|
||||
in: body
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/NotificationTrigger'
|
||||
responses:
|
||||
'200':
|
||||
description: ''
|
||||
schema:
|
||||
$ref: '#/definitions/NotificationTrigger'
|
||||
tags:
|
||||
- events
|
||||
patch:
|
||||
operationId: events_triggers_partial_update
|
||||
description: NotificationTrigger Viewset
|
||||
parameters:
|
||||
- name: data
|
||||
in: body
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/NotificationTrigger'
|
||||
responses:
|
||||
'200':
|
||||
description: ''
|
||||
schema:
|
||||
$ref: '#/definitions/NotificationTrigger'
|
||||
tags:
|
||||
- events
|
||||
delete:
|
||||
operationId: events_triggers_delete
|
||||
description: NotificationTrigger Viewset
|
||||
parameters: []
|
||||
responses:
|
||||
'204':
|
||||
description: ''
|
||||
tags:
|
||||
- events
|
||||
parameters:
|
||||
- name: pbm_uuid
|
||||
in: path
|
||||
description: A UUID string identifying this Notification Trigger.
|
||||
required: true
|
||||
type: string
|
||||
format: uuid
|
||||
/flows/bindings/:
|
||||
get:
|
||||
operationId: flows_bindings_list
|
||||
|
@ -2287,6 +2694,133 @@ paths:
|
|||
required: true
|
||||
type: string
|
||||
format: uuid
|
||||
/policies/event_matcher/:
|
||||
get:
|
||||
operationId: policies_event_matcher_list
|
||||
description: Event Matcher Policy Viewset
|
||||
parameters:
|
||||
- name: ordering
|
||||
in: query
|
||||
description: Which field to use when ordering the results.
|
||||
required: false
|
||||
type: string
|
||||
- name: search
|
||||
in: query
|
||||
description: A search term.
|
||||
required: false
|
||||
type: string
|
||||
- name: page
|
||||
in: query
|
||||
description: A page number within the paginated result set.
|
||||
required: false
|
||||
type: integer
|
||||
- name: page_size
|
||||
in: query
|
||||
description: Number of results to return per page.
|
||||
required: false
|
||||
type: integer
|
||||
responses:
|
||||
'200':
|
||||
description: ''
|
||||
schema:
|
||||
required:
|
||||
- count
|
||||
- results
|
||||
type: object
|
||||
properties:
|
||||
count:
|
||||
type: integer
|
||||
next:
|
||||
type: string
|
||||
format: uri
|
||||
x-nullable: true
|
||||
previous:
|
||||
type: string
|
||||
format: uri
|
||||
x-nullable: true
|
||||
results:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/definitions/EventMatcherPolicy'
|
||||
tags:
|
||||
- policies
|
||||
post:
|
||||
operationId: policies_event_matcher_create
|
||||
description: Event Matcher Policy Viewset
|
||||
parameters:
|
||||
- name: data
|
||||
in: body
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/EventMatcherPolicy'
|
||||
responses:
|
||||
'201':
|
||||
description: ''
|
||||
schema:
|
||||
$ref: '#/definitions/EventMatcherPolicy'
|
||||
tags:
|
||||
- policies
|
||||
parameters: []
|
||||
/policies/event_matcher/{policy_uuid}/:
|
||||
get:
|
||||
operationId: policies_event_matcher_read
|
||||
description: Event Matcher Policy Viewset
|
||||
parameters: []
|
||||
responses:
|
||||
'200':
|
||||
description: ''
|
||||
schema:
|
||||
$ref: '#/definitions/EventMatcherPolicy'
|
||||
tags:
|
||||
- policies
|
||||
put:
|
||||
operationId: policies_event_matcher_update
|
||||
description: Event Matcher Policy Viewset
|
||||
parameters:
|
||||
- name: data
|
||||
in: body
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/EventMatcherPolicy'
|
||||
responses:
|
||||
'200':
|
||||
description: ''
|
||||
schema:
|
||||
$ref: '#/definitions/EventMatcherPolicy'
|
||||
tags:
|
||||
- policies
|
||||
patch:
|
||||
operationId: policies_event_matcher_partial_update
|
||||
description: Event Matcher Policy Viewset
|
||||
parameters:
|
||||
- name: data
|
||||
in: body
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/EventMatcherPolicy'
|
||||
responses:
|
||||
'200':
|
||||
description: ''
|
||||
schema:
|
||||
$ref: '#/definitions/EventMatcherPolicy'
|
||||
tags:
|
||||
- policies
|
||||
delete:
|
||||
operationId: policies_event_matcher_delete
|
||||
description: Event Matcher Policy Viewset
|
||||
parameters: []
|
||||
responses:
|
||||
'204':
|
||||
description: ''
|
||||
tags:
|
||||
- policies
|
||||
parameters:
|
||||
- name: policy_uuid
|
||||
in: path
|
||||
description: A UUID string identifying this Group Membership Policy.
|
||||
required: true
|
||||
type: string
|
||||
format: uuid
|
||||
/policies/expression/:
|
||||
get:
|
||||
operationId: policies_expression_list
|
||||
|
@ -7083,6 +7617,105 @@ definitions:
|
|||
unique_users:
|
||||
title: Unique users
|
||||
type: integer
|
||||
Notification:
|
||||
description: Notification Serializer
|
||||
required:
|
||||
- severity
|
||||
- body
|
||||
type: object
|
||||
properties:
|
||||
pk:
|
||||
title: Uuid
|
||||
type: string
|
||||
format: uuid
|
||||
readOnly: true
|
||||
severity:
|
||||
title: Severity
|
||||
type: string
|
||||
enum:
|
||||
- notice
|
||||
- warning
|
||||
- alert
|
||||
body:
|
||||
title: Body
|
||||
type: string
|
||||
minLength: 1
|
||||
created:
|
||||
title: Created
|
||||
type: string
|
||||
format: date-time
|
||||
readOnly: true
|
||||
event:
|
||||
title: Event
|
||||
type: string
|
||||
format: uuid
|
||||
x-nullable: true
|
||||
seen:
|
||||
title: Seen
|
||||
type: boolean
|
||||
NotificationTransport:
|
||||
description: NotificationTransport Serializer
|
||||
required:
|
||||
- name
|
||||
- mode
|
||||
type: object
|
||||
properties:
|
||||
pk:
|
||||
title: Uuid
|
||||
type: string
|
||||
format: uuid
|
||||
readOnly: true
|
||||
name:
|
||||
title: Name
|
||||
type: string
|
||||
minLength: 1
|
||||
mode:
|
||||
title: Mode
|
||||
type: string
|
||||
enum:
|
||||
- webhook
|
||||
- webhook_slack
|
||||
- email
|
||||
webhook_url:
|
||||
title: Webhook url
|
||||
type: string
|
||||
NotificationTrigger:
|
||||
description: NotificationTrigger Serializer
|
||||
required:
|
||||
- name
|
||||
- transports
|
||||
type: object
|
||||
properties:
|
||||
pk:
|
||||
title: Pbm uuid
|
||||
type: string
|
||||
format: uuid
|
||||
readOnly: true
|
||||
name:
|
||||
title: Name
|
||||
type: string
|
||||
minLength: 1
|
||||
transports:
|
||||
description: Select which transports should be used to notify the user. If
|
||||
none are selected, the notification will only be shown in the authentik
|
||||
UI.
|
||||
type: array
|
||||
items:
|
||||
description: Select which transports should be used to notify the user.
|
||||
If none are selected, the notification will only be shown in the authentik
|
||||
UI.
|
||||
type: string
|
||||
format: uuid
|
||||
uniqueItems: true
|
||||
severity:
|
||||
title: Severity
|
||||
description: Controls which severity level the created notifications will
|
||||
have.
|
||||
type: string
|
||||
enum:
|
||||
- notice
|
||||
- warning
|
||||
- alert
|
||||
Stage:
|
||||
title: Stage obj
|
||||
description: Stage Serializer
|
||||
|
@ -7557,6 +8190,96 @@ definitions:
|
|||
type: integer
|
||||
maximum: 2147483647
|
||||
minimum: -2147483648
|
||||
EventMatcherPolicy:
|
||||
description: Event Matcher Policy Serializer
|
||||
type: object
|
||||
properties:
|
||||
pk:
|
||||
title: Policy uuid
|
||||
type: string
|
||||
format: uuid
|
||||
readOnly: true
|
||||
name:
|
||||
title: Name
|
||||
type: string
|
||||
x-nullable: true
|
||||
action:
|
||||
title: Action
|
||||
description: Match created events with this action type. When left empty,
|
||||
all action types will be matched.
|
||||
type: string
|
||||
enum:
|
||||
- login
|
||||
- login_failed
|
||||
- logout
|
||||
- user_write
|
||||
- suspicious_request
|
||||
- password_set
|
||||
- token_view
|
||||
- invitation_used
|
||||
- authorize_application
|
||||
- source_linked
|
||||
- impersonation_started
|
||||
- impersonation_ended
|
||||
- policy_execution
|
||||
- policy_exception
|
||||
- property_mapping_exception
|
||||
- configuration_error
|
||||
- model_created
|
||||
- model_updated
|
||||
- model_deleted
|
||||
- update_available
|
||||
- custom_
|
||||
client_ip:
|
||||
title: Client ip
|
||||
description: Matches Event's Client IP (strict matching, for network matching
|
||||
use an Expression Policy)
|
||||
type: string
|
||||
app:
|
||||
title: App
|
||||
description: Match events created by selected application. When left empty,
|
||||
all applications are matched.
|
||||
type: string
|
||||
enum:
|
||||
- authentik.admin
|
||||
- authentik.api
|
||||
- authentik.events
|
||||
- authentik.crypto
|
||||
- authentik.flows
|
||||
- authentik.outposts
|
||||
- authentik.lib
|
||||
- authentik.policies
|
||||
- authentik.policies.dummy
|
||||
- authentik.policies.event_matcher
|
||||
- authentik.policies.expiry
|
||||
- authentik.policies.expression
|
||||
- authentik.policies.group_membership
|
||||
- authentik.policies.hibp
|
||||
- authentik.policies.password
|
||||
- authentik.policies.reputation
|
||||
- authentik.providers.proxy
|
||||
- authentik.providers.oauth2
|
||||
- authentik.providers.saml
|
||||
- authentik.recovery
|
||||
- authentik.sources.ldap
|
||||
- authentik.sources.oauth
|
||||
- authentik.sources.saml
|
||||
- authentik.stages.captcha
|
||||
- authentik.stages.consent
|
||||
- authentik.stages.dummy
|
||||
- authentik.stages.email
|
||||
- authentik.stages.prompt
|
||||
- authentik.stages.identification
|
||||
- authentik.stages.invitation
|
||||
- authentik.stages.user_delete
|
||||
- authentik.stages.user_login
|
||||
- authentik.stages.user_logout
|
||||
- authentik.stages.user_write
|
||||
- authentik.stages.otp_static
|
||||
- authentik.stages.otp_time
|
||||
- authentik.stages.otp_validate
|
||||
- authentik.stages.password
|
||||
- authentik.core
|
||||
ExpressionPolicy:
|
||||
description: Group Membership Policy Serializer
|
||||
required:
|
||||
|
|
Reference in New Issue