enterprise/lifecycle: implement Object Lifecycle Management (#20015)

Co-authored-by: Tana M Berry <tanamarieberry@yahoo.com>
Co-authored-by: Jens L. <jens@beryju.org>
Co-authored-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
Co-authored-by: Dominic R <dominic@sdko.org>
Co-authored-by: Dewi Roberts <dewi@goauthentik.io>
Co-authored-by: Teffen Ellis <592134+GirlBossRush@users.noreply.github.com>
This commit is contained in:
Alexander Tereshkin
2026-02-10 19:33:06 +02:00
committed by GitHub
parent 233377e86c
commit 2f2488b326
46 changed files with 4429 additions and 49 deletions
@@ -0,0 +1,149 @@
from datetime import date
from django.db.models import BooleanField as ModelBooleanField
from django.db.models import Case, Q, Value, When
from django_filters.rest_framework import BooleanFilter, FilterSet
from drf_spectacular.utils import extend_schema, extend_schema_field
from rest_framework.decorators import action
from rest_framework.fields import DateField, IntegerField, SerializerMethodField
from rest_framework.mixins import CreateModelMixin
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.viewsets import GenericViewSet
from authentik.core.api.utils import ModelSerializer
from authentik.enterprise.api import EnterpriseRequiredMixin
from authentik.enterprise.lifecycle.api.reviews import ReviewSerializer
from authentik.enterprise.lifecycle.models import LifecycleIteration, ReviewState
from authentik.enterprise.lifecycle.utils import (
ContentTypeField,
ReviewerGroupSerializer,
ReviewerUserSerializer,
admin_link_for_model,
parse_content_type,
)
from authentik.lib.utils.time import timedelta_from_string
class LifecycleIterationSerializer(EnterpriseRequiredMixin, ModelSerializer):
content_type = ContentTypeField()
object_verbose = SerializerMethodField()
object_admin_url = SerializerMethodField(read_only=True)
grace_period_end = SerializerMethodField(read_only=True)
reviews = ReviewSerializer(many=True, read_only=True, source="review_set.all")
user_can_review = SerializerMethodField(read_only=True)
reviewer_groups = ReviewerGroupSerializer(
many=True, read_only=True, source="rule.reviewer_groups"
)
min_reviewers = IntegerField(read_only=True, source="rule.min_reviewers")
reviewers = ReviewerUserSerializer(many=True, read_only=True, source="rule.reviewers")
next_review_date = SerializerMethodField(read_only=True)
class Meta:
model = LifecycleIteration
fields = [
"id",
"content_type",
"object_id",
"object_verbose",
"object_admin_url",
"state",
"opened_on",
"grace_period_end",
"next_review_date",
"reviews",
"user_can_review",
"reviewer_groups",
"min_reviewers",
"reviewers",
]
read_only_fields = fields
def get_object_verbose(self, iteration: LifecycleIteration) -> str:
return str(iteration.object)
def get_object_admin_url(self, iteration: LifecycleIteration) -> str:
return admin_link_for_model(iteration.object)
@extend_schema_field(DateField())
def get_grace_period_end(self, iteration: LifecycleIteration) -> date:
return iteration.opened_on + timedelta_from_string(iteration.rule.grace_period)
@extend_schema_field(DateField())
def get_next_review_date(self, iteration: LifecycleIteration):
return iteration.opened_on + timedelta_from_string(iteration.rule.interval)
def get_user_can_review(self, iteration: LifecycleIteration) -> bool:
return iteration.user_can_review(self.context["request"].user)
class LifecycleIterationFilterSet(FilterSet):
user_is_reviewer = BooleanFilter(field_name="user_is_reviewer", lookup_expr="exact")
class IterationViewSet(EnterpriseRequiredMixin, CreateModelMixin, GenericViewSet):
queryset = LifecycleIteration.objects.all()
serializer_class = LifecycleIterationSerializer
ordering = ["-opened_on"]
ordering_fields = ["state", "content_type__model", "opened_on", "grace_period_end"]
filterset_class = LifecycleIterationFilterSet
def get_queryset(self):
user = self.request.user
return self.queryset.annotate(
user_is_reviewer=Case(
When(
Q(rule__reviewers=user)
| Q(rule__reviewer_groups__in=user.groups.all().with_ancestors()),
then=Value(True),
),
default=Value(False),
output_field=ModelBooleanField(),
)
)
@action(
detail=False,
methods=["get"],
url_path=r"latest/(?P<content_type>[^/]+)/(?P<object_id>[^/]+)",
)
def latest_iteration(self, request: Request, content_type: str, object_id: str) -> Response:
ct = parse_content_type(content_type)
try:
obj = (
self.get_queryset()
.filter(
content_type__app_label=ct["app_label"],
content_type__model=ct["model"],
object_id=object_id,
)
.latest("opened_on")
)
except LifecycleIteration.DoesNotExist:
return Response(status=404)
serializer = self.get_serializer(obj)
return Response(serializer.data)
@extend_schema(
operation_id="lifecycle_iterations_list_open",
responses={200: LifecycleIterationSerializer(many=True)},
)
@action(
detail=False,
methods=["get"],
url_path=r"open",
)
def open_iterations(self, request: Request):
iterations = self.get_queryset().filter(
Q(state=ReviewState.PENDING) | Q(state=ReviewState.OVERDUE)
)
iterations = self.filter_queryset(iterations)
page = self.paginate_queryset(iterations)
if page is not None:
serializer = self.get_serializer(page, many=True)
return self.get_paginated_response(serializer.data)
serializer = self.get_serializer(iterations, many=True)
return Response(serializer.data)
@@ -0,0 +1,33 @@
from django.utils.translation import gettext_lazy as _
from rest_framework.exceptions import ValidationError
from rest_framework.mixins import CreateModelMixin
from rest_framework.viewsets import GenericViewSet
from authentik.core.api.utils import ModelSerializer
from authentik.enterprise.api import EnterpriseRequiredMixin
from authentik.enterprise.lifecycle.models import LifecycleIteration, Review
from authentik.enterprise.lifecycle.utils import ReviewerUserSerializer
class ReviewSerializer(EnterpriseRequiredMixin, ModelSerializer):
reviewer = ReviewerUserSerializer(read_only=True)
class Meta:
model = Review
fields = ["id", "iteration", "reviewer", "timestamp", "note"]
read_only_fields = ["id", "timestamp", "reviewer"]
def validate_iteration(self, iteration: LifecycleIteration) -> LifecycleIteration:
user = self.context["request"].user
if not iteration.user_can_review(user):
raise ValidationError(_("You are not allowed to submit a review for this object."))
return iteration
class ReviewViewSet(EnterpriseRequiredMixin, CreateModelMixin, GenericViewSet):
queryset = Review.objects.all()
serializer_class = ReviewSerializer
def perform_create(self, serializer: ReviewSerializer) -> None:
review = serializer.save(reviewer=self.request.user)
review.iteration.on_review(self.request)
+113
View File
@@ -0,0 +1,113 @@
from django.utils.translation import gettext as _
from rest_framework.exceptions import ValidationError
from rest_framework.fields import SerializerMethodField
from rest_framework.relations import SlugRelatedField
from rest_framework.viewsets import ModelViewSet
from authentik.core.api.utils import ModelSerializer
from authentik.core.models import User
from authentik.enterprise.api import EnterpriseRequiredMixin
from authentik.enterprise.lifecycle.models import LifecycleRule
from authentik.enterprise.lifecycle.utils import (
ContentTypeField,
ReviewerGroupSerializer,
ReviewerUserSerializer,
)
from authentik.lib.utils.time import timedelta_from_string
class LifecycleRuleSerializer(EnterpriseRequiredMixin, ModelSerializer):
content_type = ContentTypeField()
target_verbose = SerializerMethodField()
reviewer_groups_obj = ReviewerGroupSerializer(
many=True, read_only=True, source="reviewer_groups"
)
reviewers = SlugRelatedField(slug_field="uuid", many=True, queryset=User.objects.all())
reviewers_obj = ReviewerUserSerializer(many=True, read_only=True, source="reviewers")
class Meta:
model = LifecycleRule
fields = [
"id",
"name",
"content_type",
"object_id",
"interval",
"grace_period",
"reviewer_groups",
"reviewer_groups_obj",
"min_reviewers",
"min_reviewers_is_per_group",
"reviewers",
"reviewers_obj",
"notification_transports",
"target_verbose",
]
read_only_fields = ["id", "reviewers_obj", "reviewer_groups_obj", "target_verbose"]
def get_target_verbose(self, rule: LifecycleRule) -> str:
if rule.object_id is None:
return rule.content_type.model_class()._meta.verbose_name_plural
else:
return f"{rule.content_type.model_class()._meta.verbose_name}: {rule.object}"
def validate_object_id(self, value: str) -> str | None:
if value == "":
return None
return value
def validate(self, attrs: dict) -> dict:
if (
attrs.get("object_id") is not None
and not attrs["content_type"]
.get_all_objects_for_this_type(pk=attrs["object_id"])
.exists()
):
raise ValidationError({"object_id": _("Object does not exist")})
if "reviewer_groups" in attrs or "reviewers" in attrs:
reviewer_groups = attrs.get(
"reviewer_groups", self.instance.reviewer_groups.all() if self.instance else []
)
reviewers = attrs.get(
"reviewers", self.instance.reviewers.all() if self.instance else []
)
if len(reviewer_groups) == 0 and len(reviewers) == 0:
raise ValidationError(_("Either a reviewer group or a reviewer must be set."))
if "grace_period" in attrs or "interval" in attrs:
grace_period = attrs.get("grace_period", getattr(self.instance, "grace_period", None))
interval = attrs.get("interval", getattr(self.instance, "interval", None))
if (
grace_period is not None
and interval is not None
and (timedelta_from_string(grace_period) > timedelta_from_string(interval))
):
raise ValidationError(
{"grace_period": _("Grace period must be shorter than the interval.")}
)
if "content_type" in attrs or "object_id" in attrs:
content_type = attrs.get("content_type", getattr(self.instance, "content_type", None))
object_id = attrs.get("object_id", getattr(self.instance, "object_id", None))
if content_type is not None and object_id is None:
existing = LifecycleRule.objects.filter(
content_type=content_type, object_id__isnull=True
)
if self.instance:
existing = existing.exclude(pk=self.instance.pk)
if existing.exists():
raise ValidationError(
{
"content_type": _(
"Only one type-wide rule for each object type is allowed."
)
}
)
return attrs
class LifecycleRuleViewSet(ModelViewSet):
queryset = LifecycleRule.objects.all()
serializer_class = LifecycleRuleSerializer
search_fields = ["content_type__model", "reviewer_groups__name", "reviewers__username"]
ordering = ["name"]
ordering_fields = ["name", "content_type__model"]
filterset_fields = ["content_type__model"]
+22
View File
@@ -0,0 +1,22 @@
from authentik.enterprise.apps import EnterpriseConfig
from authentik.lib.utils.time import fqdn_rand
from authentik.tasks.schedules.common import ScheduleSpec
class ReportsConfig(EnterpriseConfig):
name = "authentik.enterprise.lifecycle"
label = "authentik_lifecycle"
verbose_name = "authentik Enterprise.Lifecycle"
default = True
@property
def tenant_schedule_specs(self) -> list[ScheduleSpec]:
from authentik.enterprise.lifecycle.tasks import apply_lifecycle_rules
return [
ScheduleSpec(
actor=apply_lifecycle_rules,
crontab=f"{fqdn_rand('lifecycle_apply_lifecycle_rules')} "
f"{fqdn_rand('lifecycle_apply_lifecycle_rules', 24)} * * *",
)
]
@@ -0,0 +1,154 @@
# Generated by Django 5.2.11 on 2026-02-09 15:57
import authentik.lib.utils.time
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
("authentik_core", "0057_remove_user_groups_remove_user_user_permissions_and_more"),
("authentik_events", "0016_alter_event_action"),
("contenttypes", "0002_remove_content_type_name"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="LifecycleRule",
fields=[
("id", models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)),
("name", models.TextField(unique=True)),
("object_id", models.TextField(default=None, null=True)),
(
"interval",
models.TextField(
default="days=60",
validators=[authentik.lib.utils.time.timedelta_string_validator],
),
),
(
"grace_period",
models.TextField(
default="days=30",
validators=[authentik.lib.utils.time.timedelta_string_validator],
),
),
("min_reviewers", models.PositiveSmallIntegerField(default=1)),
("min_reviewers_is_per_group", models.BooleanField(default=False)),
(
"content_type",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="contenttypes.contenttype"
),
),
(
"notification_transports",
models.ManyToManyField(
blank=True,
help_text="Select which transports should be used to notify the reviewers. If none are selected, the notification will only be shown in the authentik UI.",
to="authentik_events.notificationtransport",
),
),
("reviewer_groups", models.ManyToManyField(blank=True, to="authentik_core.group")),
("reviewers", models.ManyToManyField(blank=True, to=settings.AUTH_USER_MODEL)),
],
),
migrations.CreateModel(
name="LifecycleIteration",
fields=[
(
"managed",
models.TextField(
default=None,
help_text="Objects that are managed by authentik. These objects are created and updated automatically. This flag only indicates that an object can be overwritten by migrations. You can still modify the objects via the API, but expect changes to be overwritten in a later update.",
null=True,
unique=True,
verbose_name="Managed by authentik",
),
),
("id", models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)),
("object_id", models.TextField()),
(
"state",
models.CharField(
choices=[
("REVIEWED", "Reviewed"),
("PENDING", "Pending"),
("OVERDUE", "Overdue"),
("CANCELED", "Canceled"),
],
default="PENDING",
max_length=10,
),
),
("opened_on", models.DateField(auto_now_add=True)),
(
"content_type",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="contenttypes.contenttype"
),
),
(
"rule",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to="authentik_lifecycle.lifecyclerule",
),
),
],
),
migrations.CreateModel(
name="Review",
fields=[
("id", models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)),
("timestamp", models.DateTimeField(auto_now_add=True)),
("note", models.TextField(null=True)),
(
"iteration",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="authentik_lifecycle.lifecycleiteration",
),
),
(
"reviewer",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL
),
),
],
),
migrations.AddIndex(
model_name="lifecyclerule",
index=models.Index(fields=["content_type"], name="authentik_l_content_4e3a6a_idx"),
),
migrations.AddConstraint(
model_name="lifecyclerule",
constraint=models.UniqueConstraint(
condition=models.Q(("object_id__isnull", True)),
fields=("content_type",),
name="uniq_lifecycle_rule_ct_null_object",
),
),
migrations.AlterUniqueTogether(
name="lifecyclerule",
unique_together={("content_type", "object_id")},
),
migrations.AddIndex(
model_name="lifecycleiteration",
index=models.Index(
fields=["content_type", "opened_on"], name="authentik_l_content_09c32a_idx"
),
),
migrations.AlterUniqueTogether(
name="review",
unique_together={("iteration", "reviewer")},
),
]
+287
View File
@@ -0,0 +1,287 @@
from uuid import uuid4
from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
from django.db import models
from django.db.models import Q, QuerySet
from django.db.models.fields import Field
from django.db.models.functions import Cast
from django.http import HttpRequest
from django.utils import timezone
from django.utils.translation import gettext as _
from rest_framework.serializers import BaseSerializer
from authentik.blueprints.models import ManagedModel
from authentik.core.models import Group, User
from authentik.enterprise.lifecycle.utils import link_for_model
from authentik.events.models import Event, EventAction, NotificationSeverity, NotificationTransport
from authentik.lib.models import SerializerModel
from authentik.lib.utils.time import timedelta_from_string, timedelta_string_validator
class LifecycleRule(SerializerModel):
id = models.UUIDField(primary_key=True, default=uuid4)
name = models.TextField(unique=True)
content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
object_id = models.TextField(null=True, default=None)
object = GenericForeignKey("content_type", "object_id")
interval = models.TextField(
default="days=60",
validators=[timedelta_string_validator],
)
# Grace period starts after a review is due
grace_period = models.TextField(
default="days=30",
validators=[timedelta_string_validator],
)
# The review has to be conducted by `min_reviewers` members of `reviewer_groups`
# (total or per group depending on `min_reviewers_is_per_group` flag) as well
# as all of `reviewers`
reviewer_groups = models.ManyToManyField("authentik_core.Group", blank=True)
min_reviewers = models.PositiveSmallIntegerField(default=1)
min_reviewers_is_per_group = models.BooleanField(default=False)
reviewers = models.ManyToManyField("authentik_core.User", blank=True)
notification_transports = models.ManyToManyField(
NotificationTransport,
help_text=_(
"Select which transports should be used to notify the reviewers. If none are "
"selected, the notification will only be shown in the authentik UI."
),
blank=True,
)
class Meta:
indexes = [models.Index(fields=["content_type"])]
unique_together = [["content_type", "object_id"]]
constraints = [
models.UniqueConstraint(
fields=["content_type"],
condition=Q(object_id__isnull=True),
name="uniq_lifecycle_rule_ct_null_object",
)
]
@property
def serializer(self) -> type[BaseSerializer]:
from authentik.enterprise.lifecycle.api.rules import LifecycleRuleSerializer
return LifecycleRuleSerializer
def _get_pk_field(self) -> Field:
model = self.content_type.model_class()
pk = model._meta.pk
while hasattr(pk, "target_field"):
pk = pk.target_field
return pk.__class__()
def get_objects(self) -> QuerySet:
qs = self.content_type.get_all_objects_for_this_type()
if self.object_id:
qs = qs.filter(pk=self.object_id)
else:
qs = qs.exclude(
pk__in=LifecycleRule.objects.filter(
content_type=self.content_type, object_id__isnull=False
).values_list(Cast("object_id", output_field=self._get_pk_field()), flat=True)
)
return qs
def _get_stale_iterations(self) -> QuerySet[LifecycleIteration]:
filter = ~Q(content_type=self.content_type)
if self.object_id:
filter = filter | ~Q(object_id=self.object_id)
filter = Q(state__in=(ReviewState.PENDING, ReviewState.OVERDUE)) & filter
return self.lifecycleiteration_set.filter(filter)
def _get_newly_overdue_iterations(self) -> QuerySet[LifecycleIteration]:
return self.lifecycleiteration_set.filter(
opened_on__lte=timezone.now() - timedelta_from_string(self.grace_period),
state=ReviewState.PENDING,
)
def _get_newly_due_objects(self) -> QuerySet:
recent_iteration_ids = LifecycleIteration.objects.filter(
content_type=self.content_type,
object_id__isnull=False,
opened_on__gte=timezone.now() - timedelta_from_string(self.interval),
).values_list(Cast("object_id", output_field=self._get_pk_field()), flat=True)
return self.get_objects().exclude(pk__in=recent_iteration_ids)
def apply(self):
self._get_stale_iterations().update(state=ReviewState.CANCELED)
for iteration in self._get_newly_overdue_iterations():
iteration.make_overdue()
for obj in self._get_newly_due_objects():
LifecycleIteration.start(content_type=self.content_type, object_id=obj.pk, rule=self)
def is_satisfied_for_iteration(self, iteration: LifecycleIteration) -> bool:
reviewers = self.reviewers.all()
if (
iteration.review_set.filter(reviewer__in=reviewers).distinct("reviewer").count()
< reviewers.count()
):
return False
if self.reviewer_groups.count() == 0:
return True
if self.min_reviewers_is_per_group:
for g in self.reviewer_groups.all():
if (
iteration.review_set.filter(
reviewer__groups__in=Group.objects.filter(pk=g.pk).with_descendants()
)
.distinct()
.count()
< self.min_reviewers
):
return False
return True
else:
return (
iteration.review_set.filter(
reviewer__groups__in=self.reviewer_groups.all().with_descendants()
)
.distinct()
.count()
>= self.min_reviewers
)
def get_reviewers(self) -> QuerySet[User]:
return User.objects.filter(
Q(id__in=self.reviewers.all().values_list("pk", flat=True))
| Q(groups__in=self.reviewer_groups.all().with_descendants())
).distinct()
def notify_reviewers(self, event: Event, severity: str):
from authentik.enterprise.lifecycle.tasks import send_notification
for transport in self.notification_transports.all():
for user in self.get_reviewers():
send_notification.send_with_options(
args=(transport.pk, event.pk, user.pk, severity),
rel_obj=transport,
)
if transport.send_once:
break
class ReviewState(models.TextChoices):
REVIEWED = "REVIEWED", _("Reviewed")
PENDING = "PENDING", _("Pending")
OVERDUE = "OVERDUE", _("Overdue")
CANCELED = "CANCELED", _("Canceled")
class LifecycleIteration(SerializerModel, ManagedModel):
id = models.UUIDField(primary_key=True, default=uuid4)
content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
object_id = models.TextField(null=False)
object = GenericForeignKey("content_type", "object_id")
rule = models.ForeignKey(LifecycleRule, null=True, on_delete=models.SET_NULL)
state = models.CharField(max_length=10, choices=ReviewState, default=ReviewState.PENDING)
opened_on = models.DateField(auto_now_add=True)
class Meta:
indexes = [models.Index(fields=["content_type", "opened_on"])]
@property
def serializer(self) -> type[BaseSerializer]:
from authentik.enterprise.lifecycle.api.iterations import LifecycleIterationSerializer
return LifecycleIterationSerializer
def _get_model_name(self) -> str:
return self.content_type.name.lower()
def _get_event_args(self) -> dict:
return {
"target": self.object,
"hyperlink": link_for_model(self.object),
"hyperlink_label": _(f"Go to {self._get_model_name()}"),
"lifecycle_iteration": self.id,
}
def initialize(self):
event = Event.new(
EventAction.REVIEW_INITIATED,
message=_(f"Access review is due for {self.content_type.name} {str(self.object)}"),
**self._get_event_args(),
)
event.save()
self.rule.notify_reviewers(event, NotificationSeverity.NOTICE)
def make_overdue(self):
self.state = ReviewState.OVERDUE
event = Event.new(
EventAction.REVIEW_OVERDUE,
message=_(f"Access review is overdue for {self.content_type.name} {str(self.object)}"),
**self._get_event_args(),
)
event.save()
self.rule.notify_reviewers(event, NotificationSeverity.ALERT)
self.save()
@staticmethod
def start(content_type: ContentType, object_id: str, rule: LifecycleRule) -> LifecycleIteration:
iteration = LifecycleIteration.objects.create(
content_type=content_type, object_id=object_id, rule=rule
)
iteration.initialize()
return iteration
def make_reviewed(self, request: HttpRequest):
self.state = ReviewState.REVIEWED
event = Event.new(
EventAction.REVIEW_COMPLETED,
message=_(f"Access review completed for {self.content_type.name} {str(self.object)}"),
**self._get_event_args(),
).from_http(request)
event.save()
self.rule.notify_reviewers(event, NotificationSeverity.NOTICE)
self.save()
def on_review(self, request: HttpRequest):
if self.state not in (ReviewState.PENDING, ReviewState.OVERDUE):
raise AssertionError("Review is not pending or overdue")
if self.rule.is_satisfied_for_iteration(self):
self.make_reviewed(request)
def user_can_review(self, user: User) -> bool:
if self.state not in (ReviewState.PENDING, ReviewState.OVERDUE):
return False
if self.review_set.filter(reviewer=user).exists():
return False
groups = self.rule.reviewer_groups.all()
if groups:
for group in groups:
if group.is_member(user):
return True
return False
else:
return user in self.rule.get_reviewers()
class Review(SerializerModel):
id = models.UUIDField(primary_key=True, default=uuid4)
iteration = models.ForeignKey(LifecycleIteration, on_delete=models.CASCADE)
reviewer = models.ForeignKey("authentik_core.User", on_delete=models.CASCADE)
timestamp = models.DateTimeField(auto_now_add=True)
note = models.TextField(null=True)
class Meta:
unique_together = [["iteration", "reviewer"]]
@property
def serializer(self) -> type[BaseSerializer]:
from authentik.enterprise.lifecycle.api.reviews import ReviewSerializer
return ReviewSerializer
+22
View File
@@ -0,0 +1,22 @@
from django.db.models import Q
from django.db.models.signals import post_save, pre_delete
from django.dispatch import receiver
from authentik.enterprise.lifecycle.models import LifecycleRule, ReviewState
@receiver(post_save, sender=LifecycleRule)
def post_rule_save(sender, instance: LifecycleRule, created: bool, **_):
from authentik.enterprise.lifecycle.tasks import apply_lifecycle_rule
apply_lifecycle_rule.send_with_options(
args=(instance.id,),
rel_obj=instance,
)
@receiver(pre_delete, sender=LifecycleRule)
def pre_rule_delete(sender, instance: LifecycleRule, **_):
instance.lifecycleiteration_set.filter(
Q(state=ReviewState.PENDING) | Q(state=ReviewState.OVERDUE)
).update(state=ReviewState.CANCELED)
+45
View File
@@ -0,0 +1,45 @@
from django.utils.translation import gettext_lazy as _
from dramatiq import actor
from authentik.core.models import User
from authentik.enterprise.lifecycle.models import LifecycleRule
from authentik.events.models import Event, Notification, NotificationTransport
@actor(description=_("Dispatch tasks to validate lifecycle rules."))
def apply_lifecycle_rules():
for rule in LifecycleRule.objects.all():
apply_lifecycle_rule.send_with_options(
args=(rule.id,),
rel_obj=rule,
)
@actor(description=_("Apply lifecycle rule."))
def apply_lifecycle_rule(rule_id: str):
rule = LifecycleRule.objects.filter(pk=rule_id).first()
if rule:
rule.apply()
@actor(description=_("Send lifecycle rule notification."))
def send_notification(transport_pk: int, event_pk: str, user_pk: int, severity: str):
event = Event.objects.filter(pk=event_pk).first()
if not event:
return
user = User.objects.filter(pk=user_pk).first()
if not user:
return
notification = Notification(
severity=severity,
body=event.summary,
event=event,
user=user,
hyperlink=event.hyperlink,
hyperlink_label=event.hyperlink_label,
)
transport = NotificationTransport.objects.filter(pk=transport_pk).first()
if not transport:
return
transport.send(notification)
@@ -0,0 +1,425 @@
from django.contrib.contenttypes.models import ContentType
from django.urls import reverse
from rest_framework.test import APITestCase
from authentik.core.models import Application, Group
from authentik.core.tests.utils import create_test_admin_user, create_test_user
from authentik.enterprise.lifecycle.models import LifecycleIteration, LifecycleRule, ReviewState
from authentik.enterprise.reports.tests.utils import patch_license
from authentik.lib.generators import generate_id
@patch_license
class TestLifecycleRuleAPI(APITestCase):
def setUp(self):
self.user = create_test_admin_user()
self.client.force_login(self.user)
self.app = Application.objects.create(name=generate_id(), slug=generate_id())
self.content_type = ContentType.objects.get_for_model(Application)
self.reviewer_group = Group.objects.create(name=generate_id())
def test_list_rules(self):
rule = LifecycleRule.objects.create(
name=generate_id(),
content_type=self.content_type,
object_id=str(self.app.pk),
)
rule.reviewer_groups.add(self.reviewer_group)
response = self.client.get(reverse("authentik_api:lifecyclerule-list"))
self.assertEqual(response.status_code, 200)
self.assertGreaterEqual(len(response.data["results"]), 1)
def test_create_rule_with_reviewer_group(self):
response = self.client.post(
reverse("authentik_api:lifecyclerule-list"),
{
"name": generate_id(),
"content_type": f"{self.content_type.app_label}.{self.content_type.model}",
"object_id": str(self.app.pk),
"interval": "days=30",
"grace_period": "days=10",
"reviewer_groups": [str(self.reviewer_group.pk)],
"reviewers": [],
"min_reviewers": 1,
},
)
self.assertEqual(response.status_code, 201)
self.assertEqual(response.data["object_id"], str(self.app.pk))
self.assertEqual(response.data["interval"], "days=30")
def test_create_rule_with_explicit_reviewer(self):
reviewer = create_test_user()
response = self.client.post(
reverse("authentik_api:lifecyclerule-list"),
{
"name": generate_id(),
"content_type": f"{self.content_type.app_label}.{self.content_type.model}",
"object_id": str(self.app.pk),
"interval": "days=60",
"grace_period": "days=15",
"reviewer_groups": [],
"reviewers": [str(reviewer.uuid)],
"min_reviewers": 1,
},
)
self.assertEqual(response.status_code, 201)
self.assertIn(reviewer.uuid, response.data["reviewers"])
def test_create_rule_type_level(self):
response = self.client.post(
reverse("authentik_api:lifecyclerule-list"),
{
"name": generate_id(),
"content_type": f"{self.content_type.app_label}.{self.content_type.model}",
"object_id": None,
"interval": "days=90",
"grace_period": "days=30",
"reviewer_groups": [str(self.reviewer_group.pk)],
"reviewers": [],
"min_reviewers": 1,
},
)
self.assertEqual(response.status_code, 201)
self.assertIsNone(response.data["object_id"])
def test_create_rule_fails_without_reviewers(self):
response = self.client.post(
reverse("authentik_api:lifecyclerule-list"),
{
"name": generate_id(),
"content_type": f"{self.content_type.app_label}.{self.content_type.model}",
"object_id": str(self.app.pk),
"interval": "days=30",
"grace_period": "days=10",
"reviewer_groups": [],
"reviewers": [],
"min_reviewers": 1,
},
)
self.assertEqual(response.status_code, 400)
def test_create_rule_fails_grace_period_longer_than_interval(self):
response = self.client.post(
reverse("authentik_api:lifecyclerule-list"),
{
"name": generate_id(),
"content_type": f"{self.content_type.app_label}.{self.content_type.model}",
"object_id": str(self.app.pk),
"interval": "days=10",
"grace_period": "days=30",
"reviewer_groups": [str(self.reviewer_group.pk)],
"reviewers": [],
"min_reviewers": 1,
},
)
self.assertEqual(response.status_code, 400)
self.assertIn("grace_period", response.data)
def test_create_rule_fails_invalid_object_id(self):
response = self.client.post(
reverse("authentik_api:lifecyclerule-list"),
{
"name": generate_id(),
"content_type": f"{self.content_type.app_label}.{self.content_type.model}",
"object_id": "00000000-0000-0000-0000-000000000000",
"interval": "days=30",
"grace_period": "days=10",
"reviewer_groups": [str(self.reviewer_group.pk)],
"reviewers": [],
"min_reviewers": 1,
},
)
self.assertEqual(response.status_code, 400)
self.assertIn("object_id", response.data)
def test_retrieve_rule(self):
rule = LifecycleRule.objects.create(
name=generate_id(),
content_type=self.content_type,
object_id=str(self.app.pk),
)
rule.reviewer_groups.add(self.reviewer_group)
response = self.client.get(
reverse("authentik_api:lifecyclerule-detail", kwargs={"pk": rule.pk})
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["id"], str(rule.pk))
def test_update_rule(self):
rule = LifecycleRule.objects.create(
name=generate_id(),
content_type=self.content_type,
object_id=str(self.app.pk),
interval="days=30",
)
rule.reviewer_groups.add(self.reviewer_group)
response = self.client.patch(
reverse("authentik_api:lifecyclerule-detail", kwargs={"pk": rule.pk}),
{"interval": "days=60"},
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["interval"], "days=60")
def test_delete_rule(self):
rule = LifecycleRule.objects.create(
name=generate_id(),
content_type=self.content_type,
object_id=str(self.app.pk),
)
rule.reviewer_groups.add(self.reviewer_group)
response = self.client.delete(
reverse("authentik_api:lifecyclerule-detail", kwargs={"pk": rule.pk})
)
self.assertEqual(response.status_code, 204)
self.assertFalse(LifecycleRule.objects.filter(pk=rule.pk).exists())
@patch_license
class TestIterationAPI(APITestCase):
def setUp(self):
self.user = create_test_admin_user()
self.client.force_login(self.user)
self.app = Application.objects.create(name=generate_id(), slug=generate_id())
self.content_type = ContentType.objects.get_for_model(Application)
self.reviewer_group = Group.objects.create(name=generate_id())
self.reviewer_group.users.add(self.user)
def test_open_iterations(self):
rule = LifecycleRule.objects.create(
name=generate_id(),
content_type=self.content_type,
object_id=str(self.app.pk),
)
rule.reviewer_groups.add(self.reviewer_group)
response = self.client.get(reverse("authentik_api:lifecycleiteration-open-iterations"))
self.assertEqual(response.status_code, 200)
self.assertGreaterEqual(len(response.data["results"]), 1)
for iteration in response.data["results"]:
self.assertEqual(iteration["state"], ReviewState.PENDING)
def test_open_iterations_filter_user_is_reviewer(self):
rule = LifecycleRule.objects.create(
name=generate_id(),
content_type=self.content_type,
object_id=str(self.app.pk),
)
rule.reviewer_groups.add(self.reviewer_group)
response = self.client.get(
reverse("authentik_api:lifecycleiteration-open-iterations"),
{"user_is_reviewer": "true"},
)
self.assertEqual(response.status_code, 200)
# User is in reviewer_group, so should see the iteration
self.assertGreaterEqual(len(response.data["results"]), 1)
def test_latest_iteration(self):
rule = LifecycleRule.objects.create(
name=generate_id(),
content_type=self.content_type,
object_id=str(self.app.pk),
)
rule.reviewer_groups.add(self.reviewer_group)
response = self.client.get(
reverse(
"authentik_api:lifecycleiteration-latest-iteration",
kwargs={
"content_type": f"{self.content_type.app_label}.{self.content_type.model}",
"object_id": str(self.app.pk),
},
)
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["object_id"], str(self.app.pk))
def test_latest_iteration_not_found(self):
response = self.client.get(
reverse(
"authentik_api:lifecycleiteration-latest-iteration",
kwargs={
"content_type": f"{self.content_type.app_label}.{self.content_type.model}",
"object_id": "00000000-0000-0000-0000-000000000000",
},
)
)
self.assertEqual(response.status_code, 404)
def test_iteration_includes_user_can_review(self):
rule = LifecycleRule.objects.create(
name=generate_id(),
content_type=self.content_type,
object_id=str(self.app.pk),
)
rule.reviewer_groups.add(self.reviewer_group)
response = self.client.get(reverse("authentik_api:lifecycleiteration-open-iterations"))
self.assertEqual(response.status_code, 200)
self.assertGreaterEqual(len(response.data["results"]), 1)
# user_can_review should be present
self.assertIn("user_can_review", response.data["results"][0])
@patch_license
class TestReviewAPI(APITestCase):
def setUp(self):
self.user = create_test_admin_user()
self.client.force_login(self.user)
self.app = Application.objects.create(name=generate_id(), slug=generate_id())
self.content_type = ContentType.objects.get_for_model(Application)
self.reviewer_group = Group.objects.create(name=generate_id())
self.reviewer_group.users.add(self.user)
def test_create_review(self):
rule = LifecycleRule.objects.create(
name=generate_id(),
content_type=self.content_type,
object_id=str(self.app.pk),
min_reviewers=1,
)
rule.reviewer_groups.add(self.reviewer_group)
# Get the auto-created iteration
iteration = LifecycleIteration.objects.get(
content_type=self.content_type, object_id=str(self.app.pk), rule=rule
)
response = self.client.post(
reverse("authentik_api:review-list"),
{
"iteration": str(iteration.pk),
"note": "Reviewed and approved",
},
)
self.assertEqual(response.status_code, 201)
self.assertEqual(response.data["iteration"], iteration.pk)
self.assertEqual(response.data["note"], "Reviewed and approved")
self.assertEqual(response.data["reviewer"]["pk"], self.user.pk)
def test_create_review_completes_iteration(self):
rule = LifecycleRule.objects.create(
name=generate_id(),
content_type=self.content_type,
object_id=str(self.app.pk),
min_reviewers=1,
)
rule.reviewer_groups.add(self.reviewer_group)
iteration = LifecycleIteration.objects.get(
content_type=self.content_type, object_id=str(self.app.pk), rule=rule
)
self.assertEqual(iteration.state, ReviewState.PENDING)
response = self.client.post(
reverse("authentik_api:review-list"),
{
"iteration": str(iteration.pk),
},
)
self.assertEqual(response.status_code, 201)
iteration.refresh_from_db()
self.assertEqual(iteration.state, ReviewState.REVIEWED)
def test_create_review_sets_reviewer_from_request(self):
rule = LifecycleRule.objects.create(
name=generate_id(),
content_type=self.content_type,
object_id=str(self.app.pk),
min_reviewers=1,
)
rule.reviewer_groups.add(self.reviewer_group)
iteration = LifecycleIteration.objects.get(
content_type=self.content_type, object_id=str(self.app.pk), rule=rule
)
response = self.client.post(
reverse("authentik_api:review-list"),
{
"iteration": str(iteration.pk),
},
)
self.assertEqual(response.status_code, 201)
# Reviewer should be the logged-in user
self.assertEqual(response.data["reviewer"]["pk"], self.user.pk)
def test_non_reviewer_cannot_review(self):
other_group = Group.objects.create(name=generate_id())
other_user = create_test_user()
other_group.users.add(other_user)
rule = LifecycleRule.objects.create(
name=generate_id(),
content_type=self.content_type,
object_id=str(self.app.pk),
min_reviewers=1,
)
rule.reviewer_groups.add(other_group)
iteration = LifecycleIteration.objects.get(
content_type=self.content_type, object_id=str(self.app.pk), rule=rule
)
# Current user is not in the reviewer group
self.assertFalse(iteration.user_can_review(self.user))
def test_non_reviewer_review_via_api_rejected(self):
other_group = Group.objects.create(name=generate_id())
other_user = create_test_user()
other_group.users.add(other_user)
rule = LifecycleRule.objects.create(
name=generate_id(),
content_type=self.content_type,
object_id=str(self.app.pk),
min_reviewers=1,
)
rule.reviewer_groups.add(other_group)
iteration = LifecycleIteration.objects.get(
content_type=self.content_type, object_id=str(self.app.pk), rule=rule
)
# Current user (self.user) is NOT in the reviewer group
response = self.client.post(
reverse("authentik_api:review-list"),
{"iteration": str(iteration.pk)},
)
self.assertEqual(response.status_code, 400)
def test_duplicate_review_via_api_rejected(self):
rule = LifecycleRule.objects.create(
name=generate_id(),
content_type=self.content_type,
object_id=str(self.app.pk),
min_reviewers=2,
)
rule.reviewer_groups.add(self.reviewer_group)
iteration = LifecycleIteration.objects.get(
content_type=self.content_type, object_id=str(self.app.pk), rule=rule
)
# First review should succeed
response = self.client.post(
reverse("authentik_api:review-list"),
{"iteration": str(iteration.pk)},
)
self.assertEqual(response.status_code, 201)
# Second review by same user should be rejected
response = self.client.post(
reverse("authentik_api:review-list"),
{"iteration": str(iteration.pk)},
)
self.assertEqual(response.status_code, 400)
@@ -0,0 +1,669 @@
from datetime import timedelta
from unittest.mock import patch
from django.contrib.contenttypes.models import ContentType
from django.test import RequestFactory, TestCase
from django.utils import timezone
from authentik.core.models import Application, Group
from authentik.core.tests.utils import create_test_user
from authentik.enterprise.lifecycle.models import (
LifecycleIteration,
LifecycleRule,
Review,
ReviewState,
)
from authentik.events.models import (
Event,
EventAction,
NotificationSeverity,
NotificationTransport,
)
from authentik.lib.generators import generate_id
from authentik.rbac.models import Role
class TestLifecycleModels(TestCase):
def setUp(self):
self.factory = RequestFactory()
def _get_request(self):
return self.factory.get("/")
def _create_object(self, model):
if model is Application:
return Application.objects.create(name=generate_id(), slug=generate_id())
if model is Role:
return Role.objects.create(name=generate_id())
if model is Group:
return Group.objects.create(name=generate_id())
raise AssertionError(f"Unsupported model {model}")
def _create_rule_for_object(self, obj, **kwargs) -> LifecycleRule:
content_type = ContentType.objects.get_for_model(obj)
return LifecycleRule.objects.create(
name=generate_id(),
content_type=content_type,
object_id=str(obj.pk),
**kwargs,
)
def _create_rule_for_type(self, model, **kwargs) -> LifecycleRule:
content_type = ContentType.objects.get_for_model(model)
return LifecycleRule.objects.create(
name=generate_id(),
content_type=content_type,
object_id=None,
**kwargs,
)
def test_iteration_start_supported_objects(self):
"""Ensure iterations are automatically started for applications, roles, and groups."""
for model in (Application, Role, Group):
with self.subTest(model=model.__name__):
obj = self._create_object(model)
content_type = ContentType.objects.get_for_model(obj)
before_events = Event.objects.filter(action=EventAction.REVIEW_INITIATED).count()
rule = self._create_rule_for_object(obj)
# Verify iteration was created automatically
iteration = LifecycleIteration.objects.get(
content_type=content_type, object_id=str(obj.pk), rule=rule
)
self.assertEqual(iteration.state, ReviewState.PENDING)
self.assertEqual(iteration.object, obj)
self.assertEqual(iteration.rule, rule)
self.assertEqual(
Event.objects.filter(action=EventAction.REVIEW_INITIATED).count(),
before_events + 1,
)
def test_review_requires_all_explicit_reviewers(self):
obj = Group.objects.create(name=generate_id())
rule = self._create_rule_for_object(obj)
reviewer_one = create_test_user()
reviewer_two = create_test_user()
rule.reviewers.add(reviewer_one, reviewer_two)
content_type = ContentType.objects.get_for_model(obj)
iteration = LifecycleIteration.objects.get(
content_type=content_type, object_id=str(obj.pk), rule=rule
)
request = self._get_request()
Review.objects.create(iteration=iteration, reviewer=reviewer_one)
iteration.on_review(request)
iteration.refresh_from_db()
self.assertEqual(iteration.state, ReviewState.PENDING)
Review.objects.create(iteration=iteration, reviewer=reviewer_two)
iteration.on_review(request)
iteration.refresh_from_db()
self.assertEqual(iteration.state, ReviewState.REVIEWED)
self.assertTrue(Event.objects.filter(action=EventAction.REVIEW_COMPLETED).exists())
def test_review_min_reviewers_from_groups(self):
"""Group-based reviews complete once the minimum number of reviewers review."""
obj = Application.objects.create(name=generate_id(), slug=generate_id())
rule = self._create_rule_for_object(obj, min_reviewers=2)
reviewer_group = Group.objects.create(name=generate_id())
reviewer_one = create_test_user()
reviewer_two = create_test_user()
reviewer_group.users.add(reviewer_one, reviewer_two)
rule.reviewer_groups.add(reviewer_group)
content_type = ContentType.objects.get_for_model(obj)
iteration = LifecycleIteration.objects.get(
content_type=content_type, object_id=str(obj.pk), rule=rule
)
request = self._get_request()
Review.objects.create(iteration=iteration, reviewer=reviewer_one)
iteration.on_review(request)
iteration.refresh_from_db()
self.assertEqual(iteration.state, ReviewState.PENDING)
Review.objects.create(iteration=iteration, reviewer=reviewer_two)
iteration.on_review(request)
iteration.refresh_from_db()
self.assertEqual(iteration.state, ReviewState.REVIEWED)
def test_review_explicit_and_group_reviewers(self):
"""Reviews require both explicit reviewers AND min_reviewers from groups."""
obj = Application.objects.create(name=generate_id(), slug=generate_id())
rule = self._create_rule_for_object(obj, min_reviewers=1)
reviewer_group = Group.objects.create(name=generate_id())
group_member = create_test_user()
reviewer_group.users.add(group_member)
rule.reviewer_groups.add(reviewer_group)
explicit_reviewer = create_test_user()
rule.reviewers.add(explicit_reviewer)
content_type = ContentType.objects.get_for_model(obj)
iteration = LifecycleIteration.objects.get(
content_type=content_type, object_id=str(obj.pk), rule=rule
)
request = self._get_request()
# Only group member reviews - not satisfied (explicit reviewer missing)
Review.objects.create(iteration=iteration, reviewer=group_member)
iteration.on_review(request)
iteration.refresh_from_db()
self.assertEqual(iteration.state, ReviewState.PENDING)
# Explicit reviewer reviews - now satisfied
Review.objects.create(iteration=iteration, reviewer=explicit_reviewer)
iteration.on_review(request)
iteration.refresh_from_db()
self.assertEqual(iteration.state, ReviewState.REVIEWED)
def test_review_min_reviewers_per_group(self):
obj = Application.objects.create(name=generate_id(), slug=generate_id())
rule = self._create_rule_for_object(obj, min_reviewers=1, min_reviewers_is_per_group=True)
group_one = Group.objects.create(name=generate_id())
group_two = Group.objects.create(name=generate_id())
member_group_one = create_test_user()
member_group_two = create_test_user()
group_one.users.add(member_group_one)
group_two.users.add(member_group_two)
rule.reviewer_groups.add(group_one, group_two)
content_type = ContentType.objects.get_for_model(obj)
iteration = LifecycleIteration.objects.get(
content_type=content_type, object_id=str(obj.pk), rule=rule
)
request = self._get_request()
# Only member from group_one reviews - not satisfied (need member from each group)
Review.objects.create(iteration=iteration, reviewer=member_group_one)
iteration.on_review(request)
iteration.refresh_from_db()
self.assertEqual(iteration.state, ReviewState.PENDING)
# Member from group_two reviews - now satisfied
Review.objects.create(iteration=iteration, reviewer=member_group_two)
iteration.on_review(request)
iteration.refresh_from_db()
self.assertEqual(iteration.state, ReviewState.REVIEWED)
def test_review_reviewers_from_child_groups(self):
obj = Application.objects.create(name=generate_id(), slug=generate_id())
rule = self._create_rule_for_object(obj, min_reviewers=1)
parent_group = Group.objects.create(name=generate_id())
child_group = Group.objects.create(name=generate_id())
child_group.parents.add(parent_group)
child_member = create_test_user()
child_group.users.add(child_member)
rule.reviewer_groups.add(parent_group)
content_type = ContentType.objects.get_for_model(obj)
iteration = LifecycleIteration.objects.get(
content_type=content_type, object_id=str(obj.pk), rule=rule
)
request = self._get_request()
# Child group member should be able to review
self.assertTrue(iteration.user_can_review(child_member))
Review.objects.create(iteration=iteration, reviewer=child_member)
iteration.on_review(request)
iteration.refresh_from_db()
self.assertEqual(iteration.state, ReviewState.REVIEWED)
def test_review_reviewers_from_nested_child_groups(self):
obj = Application.objects.create(name=generate_id(), slug=generate_id())
rule = self._create_rule_for_object(obj, min_reviewers=2)
grandparent = Group.objects.create(name=generate_id())
parent = Group.objects.create(name=generate_id())
child = Group.objects.create(name=generate_id())
parent.parents.add(grandparent)
child.parents.add(parent)
parent_member = create_test_user()
child_member = create_test_user()
parent.users.add(parent_member)
child.users.add(child_member)
rule.reviewer_groups.add(grandparent)
content_type = ContentType.objects.get_for_model(obj)
iteration = LifecycleIteration.objects.get(
content_type=content_type, object_id=str(obj.pk), rule=rule
)
request = self._get_request()
# Both nested members should be able to review
self.assertTrue(iteration.user_can_review(parent_member))
self.assertTrue(iteration.user_can_review(child_member))
Review.objects.create(iteration=iteration, reviewer=parent_member)
iteration.on_review(request)
iteration.refresh_from_db()
self.assertEqual(iteration.state, ReviewState.PENDING)
Review.objects.create(iteration=iteration, reviewer=child_member)
iteration.on_review(request)
iteration.refresh_from_db()
self.assertEqual(iteration.state, ReviewState.REVIEWED)
def test_notify_reviewers_send_once(self):
obj = Group.objects.create(name=generate_id())
rule = self._create_rule_for_object(obj)
reviewer_one = create_test_user()
reviewer_two = create_test_user()
rule.reviewers.add(reviewer_one, reviewer_two)
transport_once = NotificationTransport.objects.create(
name=generate_id(),
send_once=True,
)
transport_all = NotificationTransport.objects.create(
name=generate_id(),
send_once=False,
)
rule.notification_transports.add(transport_once, transport_all)
event = Event.new(EventAction.REVIEW_INITIATED, target=obj)
event.save()
with patch(
"authentik.enterprise.lifecycle.tasks.send_notification.send_with_options"
) as send_with_options:
rule.notify_reviewers(event, NotificationSeverity.NOTICE)
reviewer_pks = {reviewer_one.pk, reviewer_two.pk}
self.assertEqual(send_with_options.call_count, len(reviewer_pks) + 1)
calls = [call.kwargs["args"] for call in send_with_options.call_args_list]
once_calls = [args for args in calls if args[0] == transport_once.pk]
all_calls = [args for args in calls if args[0] == transport_all.pk]
self.assertEqual(len(once_calls), 1)
self.assertEqual(len(all_calls), len(reviewer_pks))
self.assertIn(once_calls[0][2], reviewer_pks)
self.assertEqual({args[2] for args in all_calls}, reviewer_pks)
def test_apply_marks_overdue_and_opens_due_reviews(self):
app_one = Application.objects.create(name=generate_id(), slug=generate_id())
app_two = Application.objects.create(name=generate_id(), slug=generate_id())
content_type = ContentType.objects.get_for_model(Application)
rule_overdue = LifecycleRule.objects.create(
name=generate_id(),
content_type=content_type,
object_id=str(app_one.pk),
interval="days=365",
grace_period="days=10",
)
# Get the automatically created iteration and backdate it past the grace period
iteration = LifecycleIteration.objects.get(
content_type=content_type, object_id=str(app_one.pk), rule=rule_overdue
)
LifecycleIteration.objects.filter(pk=iteration.pk).update(
opened_on=(timezone.now().date() - timedelta(days=20))
)
# Apply again to trigger overdue logic
rule_overdue.apply()
iteration.refresh_from_db()
self.assertEqual(iteration.state, ReviewState.OVERDUE)
self.assertEqual(
LifecycleIteration.objects.filter(
content_type=content_type, object_id=str(app_one.pk)
).count(),
1,
)
LifecycleRule.objects.create(
name=generate_id(),
content_type=content_type,
object_id=str(app_two.pk),
interval="days=30",
grace_period="days=10",
)
self.assertEqual(
LifecycleIteration.objects.filter(
content_type=content_type, object_id=str(app_two.pk)
).count(),
1,
)
new_iteration = LifecycleIteration.objects.get(
content_type=content_type, object_id=str(app_two.pk)
)
self.assertEqual(new_iteration.state, ReviewState.PENDING)
def test_apply_idempotent(self):
app_due = Application.objects.create(name=generate_id(), slug=generate_id())
app_overdue = Application.objects.create(name=generate_id(), slug=generate_id())
content_type = ContentType.objects.get_for_model(Application)
initiated_before = Event.objects.filter(action=EventAction.REVIEW_INITIATED).count()
overdue_before = Event.objects.filter(action=EventAction.REVIEW_OVERDUE).count()
rule_due = LifecycleRule.objects.create(
name=generate_id(),
content_type=content_type,
object_id=str(app_due.pk),
interval="days=30",
grace_period="days=30",
)
reviewer = create_test_user()
rule_due.reviewers.add(reviewer)
transport = NotificationTransport.objects.create(name=generate_id())
rule_due.notification_transports.add(transport)
rule_overdue = LifecycleRule.objects.create(
name=generate_id(),
content_type=content_type,
object_id=str(app_overdue.pk),
interval="days=365",
grace_period="days=10",
)
overdue_iteration = LifecycleIteration.objects.get(
content_type=content_type, object_id=str(app_overdue.pk), rule=rule_overdue
)
LifecycleIteration.objects.filter(pk=overdue_iteration.pk).update(
opened_on=(timezone.now().date() - timedelta(days=20))
)
# Apply overdue rule to mark iteration as overdue
rule_overdue.apply()
due_iteration = LifecycleIteration.objects.get(
content_type=content_type, object_id=str(app_due.pk)
)
overdue_iteration.refresh_from_db()
self.assertEqual(due_iteration.state, ReviewState.PENDING)
self.assertEqual(overdue_iteration.state, ReviewState.OVERDUE)
initiated_after_first = Event.objects.filter(action=EventAction.REVIEW_INITIATED).count()
overdue_after_first = Event.objects.filter(action=EventAction.REVIEW_OVERDUE).count()
# Both rules created iterations on save
self.assertEqual(initiated_after_first, initiated_before + 2)
self.assertEqual(overdue_after_first, overdue_before + 1)
# Apply again - should be idempotent
rule_due.apply()
rule_overdue.apply()
due_iteration.refresh_from_db()
overdue_iteration.refresh_from_db()
self.assertEqual(due_iteration.state, ReviewState.PENDING)
self.assertEqual(overdue_iteration.state, ReviewState.OVERDUE)
self.assertEqual(
Event.objects.filter(action=EventAction.REVIEW_INITIATED).count(),
initiated_after_first,
)
self.assertEqual(
Event.objects.filter(action=EventAction.REVIEW_OVERDUE).count(),
overdue_after_first,
)
def test_rule_matches_entire_type(self):
"""A rule with object_id=None matches all objects of that type."""
app_one = Application.objects.create(name=generate_id(), slug=generate_id())
app_two = Application.objects.create(name=generate_id(), slug=generate_id())
content_type = ContentType.objects.get_for_model(Application)
rule = LifecycleRule.objects.create(
name=generate_id(),
content_type=content_type,
object_id=None,
interval="days=30",
grace_period="days=10",
)
objects = list(rule.get_objects())
self.assertIn(app_one, objects)
self.assertIn(app_two, objects)
def test_rule_type_excludes_objects_with_specific_rules(self):
app_with_rule = Application.objects.create(name=generate_id(), slug=generate_id())
app_without_rule = Application.objects.create(name=generate_id(), slug=generate_id())
content_type = ContentType.objects.get_for_model(Application)
# Create a specific rule for app_with_rule
LifecycleRule.objects.create(
name=generate_id(),
content_type=content_type,
object_id=str(app_with_rule.pk),
interval="days=30",
)
# Create a type-level rule
type_rule = LifecycleRule.objects.create(
name=generate_id(),
content_type=content_type,
object_id=None,
interval="days=60",
)
objects = list(type_rule.get_objects())
self.assertNotIn(app_with_rule, objects)
self.assertIn(app_without_rule, objects)
def test_rule_type_apply_creates_iterations_for_all_objects(self):
app_one = Application.objects.create(name=generate_id(), slug=generate_id())
app_two = Application.objects.create(name=generate_id(), slug=generate_id())
content_type = ContentType.objects.get_for_model(Application)
LifecycleRule.objects.create(
name=generate_id(),
content_type=content_type,
object_id=None,
interval="days=30",
grace_period="days=10",
)
self.assertTrue(
LifecycleIteration.objects.filter(
content_type=content_type, object_id=str(app_one.pk)
).exists()
)
self.assertTrue(
LifecycleIteration.objects.filter(
content_type=content_type, object_id=str(app_two.pk)
).exists()
)
def test_delete_rule_cancels_open_iterations(self):
obj = Application.objects.create(name=generate_id(), slug=generate_id())
rule = self._create_rule_for_object(obj)
content_type = ContentType.objects.get_for_model(obj)
pending_iteration = LifecycleIteration.objects.get(
content_type=content_type, object_id=str(obj.pk), rule=rule
)
self.assertEqual(pending_iteration.state, ReviewState.PENDING)
overdue_iteration = LifecycleIteration.objects.create(
content_type=content_type,
object_id=str(obj.pk),
rule=rule,
state=ReviewState.OVERDUE,
)
reviewed_iteration = LifecycleIteration.objects.create(
content_type=content_type,
object_id=str(obj.pk),
rule=rule,
state=ReviewState.REVIEWED,
)
rule.delete()
pending_iteration.refresh_from_db()
overdue_iteration.refresh_from_db()
reviewed_iteration.refresh_from_db()
self.assertEqual(pending_iteration.state, ReviewState.CANCELED)
self.assertEqual(overdue_iteration.state, ReviewState.CANCELED)
self.assertEqual(reviewed_iteration.state, ReviewState.REVIEWED) # Not affected
def test_update_rule_target_cancels_stale_iterations(self):
app_one = Application.objects.create(name=generate_id(), slug=generate_id())
app_two = Application.objects.create(name=generate_id(), slug=generate_id())
content_type = ContentType.objects.get_for_model(Application)
rule = LifecycleRule.objects.create(
name=generate_id(),
content_type=content_type,
object_id=str(app_one.pk),
interval="days=30",
)
iteration_for_app_one = LifecycleIteration.objects.get(
content_type=content_type, object_id=str(app_one.pk), rule=rule
)
self.assertEqual(iteration_for_app_one.state, ReviewState.PENDING)
# Change rule target to app_two - save() triggers apply() which cancels stale iterations
rule.object_id = str(app_two.pk)
rule.save()
iteration_for_app_one.refresh_from_db()
self.assertEqual(iteration_for_app_one.state, ReviewState.CANCELED)
def test_update_rule_content_type_cancels_stale_iterations(self):
app = Application.objects.create(name=generate_id(), slug=generate_id())
group = Group.objects.create(name=generate_id())
app_content_type = ContentType.objects.get_for_model(Application)
group_content_type = ContentType.objects.get_for_model(Group)
# Creating rule triggers automatic apply() which creates a iteration for app
rule = LifecycleRule.objects.create(
name=generate_id(),
content_type=app_content_type,
object_id=str(app.pk),
interval="days=30",
)
iteration = LifecycleIteration.objects.get(
content_type=app_content_type, object_id=str(app.pk), rule=rule
)
self.assertEqual(iteration.state, ReviewState.PENDING)
# Change content type to Group - save() triggers apply() which cancels stale iterations
rule.content_type = group_content_type
rule.object_id = str(group.pk)
rule.save()
iteration.refresh_from_db()
self.assertEqual(iteration.state, ReviewState.CANCELED)
def test_user_can_review_checks_group_hierarchy(self):
obj = Application.objects.create(name=generate_id(), slug=generate_id())
rule = self._create_rule_for_object(obj)
parent_group = Group.objects.create(name=generate_id())
child_group = Group.objects.create(name=generate_id())
child_group.parents.add(parent_group)
parent_member = create_test_user()
child_member = create_test_user()
non_member = create_test_user()
parent_group.users.add(parent_member)
child_group.users.add(child_member)
rule.reviewer_groups.add(parent_group)
content_type = ContentType.objects.get_for_model(obj)
# iteration is created automatically when rule is saved
iteration = LifecycleIteration.objects.get(
content_type=content_type, object_id=str(obj.pk), rule=rule
)
self.assertTrue(iteration.user_can_review(parent_member))
self.assertTrue(iteration.user_can_review(child_member))
self.assertFalse(iteration.user_can_review(non_member))
def test_user_cannot_review_twice(self):
obj = Application.objects.create(name=generate_id(), slug=generate_id())
rule = self._create_rule_for_object(obj)
reviewer = create_test_user()
rule.reviewers.add(reviewer)
content_type = ContentType.objects.get_for_model(obj)
# iteration is created automatically when rule is saved
iteration = LifecycleIteration.objects.get(
content_type=content_type, object_id=str(obj.pk), rule=rule
)
self.assertTrue(iteration.user_can_review(reviewer))
Review.objects.create(iteration=iteration, reviewer=reviewer)
self.assertFalse(iteration.user_can_review(reviewer))
def test_user_cannot_review_completed_iteration(self):
obj = Application.objects.create(name=generate_id(), slug=generate_id())
rule = self._create_rule_for_object(obj)
reviewer = create_test_user()
rule.reviewers.add(reviewer)
content_type = ContentType.objects.get_for_model(obj)
# Get the automatically created pending iteration and test with different states
iteration = LifecycleIteration.objects.get(
content_type=content_type, object_id=str(obj.pk), rule=rule
)
for state in (ReviewState.REVIEWED, ReviewState.CANCELED):
iteration.state = state
iteration.save()
self.assertFalse(iteration.user_can_review(reviewer))
def test_get_reviewers_includes_child_group_members(self):
obj = Application.objects.create(name=generate_id(), slug=generate_id())
rule = self._create_rule_for_object(obj)
parent_group = Group.objects.create(name=generate_id())
child_group = Group.objects.create(name=generate_id())
child_group.parents.add(parent_group)
parent_member = create_test_user()
child_member = create_test_user()
parent_group.users.add(parent_member)
child_group.users.add(child_member)
rule.reviewer_groups.add(parent_group)
reviewers = list(rule.get_reviewers())
self.assertIn(parent_member, reviewers)
self.assertIn(child_member, reviewers)
def test_get_reviewers_includes_explicit_reviewers(self):
obj = Application.objects.create(name=generate_id(), slug=generate_id())
rule = self._create_rule_for_object(obj)
explicit_reviewer = create_test_user()
rule.reviewers.add(explicit_reviewer)
group = Group.objects.create(name=generate_id())
group_member = create_test_user()
group.users.add(group_member)
rule.reviewer_groups.add(group)
reviewers = list(rule.get_reviewers())
self.assertIn(explicit_reviewer, reviewers)
self.assertIn(group_member, reviewers)
+11
View File
@@ -0,0 +1,11 @@
"""API URLs"""
from authentik.enterprise.lifecycle.api.iterations import IterationViewSet
from authentik.enterprise.lifecycle.api.reviews import ReviewViewSet
from authentik.enterprise.lifecycle.api.rules import LifecycleRuleViewSet
api_urlpatterns = [
("lifecycle/iterations", IterationViewSet),
("lifecycle/reviews", ReviewViewSet),
("lifecycle/rules", LifecycleRuleViewSet),
]
+70
View File
@@ -0,0 +1,70 @@
from urllib import parse
from django.contrib.contenttypes.models import ContentType
from django.db.models import Model
from django.urls import reverse
from rest_framework.serializers import ChoiceField, Serializer, UUIDField
from authentik.core.api.utils import ModelSerializer
from authentik.core.models import Application, Group, User
from authentik.rbac.models import Role
def parse_content_type(value: str) -> dict:
app_label, model = value.split(".")
return {"app_label": app_label, "model": model}
def model_choices() -> list[tuple[str, str]]:
return [
("authentik_core.application", "Application"),
("authentik_core.group", "Group"),
("authentik_rbac.role", "Role"),
]
def admin_link_for_model(model: Model) -> str:
if isinstance(model, Application):
url = f"/core/applications/{model.slug}"
elif isinstance(model, Group):
url = f"/identity/groups/{model.pk}"
elif isinstance(model, Role):
url = f"/identity/roles/{model.pk}"
else:
raise TypeError("Unsupported model")
return url + ";" + parse.quote('{"page":"page-lifecycle"}')
def link_for_model(model: Model) -> str:
return f"{reverse("authentik_core:if-admin")}#{admin_link_for_model(model)}"
class ContentTypeField(ChoiceField):
def __init__(self, **kwargs):
super().__init__(choices=model_choices(), **kwargs)
def to_representation(self, content_type: ContentType) -> str:
return f"{content_type.app_label}.{content_type.model}"
def to_internal_value(self, data: str) -> ContentType:
return ContentType.objects.get(**parse_content_type(data))
class GenericForeignKeySerializer(Serializer):
content_type = ContentTypeField()
object_id = UUIDField()
class ReviewerGroupSerializer(ModelSerializer):
class Meta:
model = Group
fields = [
"pk",
"name",
]
class ReviewerUserSerializer(ModelSerializer):
class Meta:
model = User
fields = ["pk", "uuid", "username", "name"]
+1
View File
@@ -4,6 +4,7 @@ TENANT_APPS = [
"authentik.enterprise.audit",
"authentik.enterprise.endpoints.connectors.agent",
"authentik.enterprise.endpoints.connectors.fleet",
"authentik.enterprise.lifecycle",
"authentik.enterprise.policies.unique_password",
"authentik.enterprise.providers.google_workspace",
"authentik.enterprise.providers.microsoft_entra",
@@ -0,0 +1,54 @@
# Generated by Django 5.2.10 on 2026-02-03 09:52
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("authentik_events", "0015_alter_event_action_choices"),
]
operations = [
migrations.AlterField(
model_name="event",
name="action",
field=models.TextField(
choices=[
("login", "Login"),
("login_failed", "Login Failed"),
("logout", "Logout"),
("user_write", "User Write"),
("suspicious_request", "Suspicious Request"),
("password_set", "Password Set"),
("secret_view", "Secret View"),
("secret_rotate", "Secret Rotate"),
("invitation_used", "Invite Used"),
("authorize_application", "Authorize Application"),
("source_linked", "Source Linked"),
("impersonation_started", "Impersonation Started"),
("impersonation_ended", "Impersonation Ended"),
("flow_execution", "Flow Execution"),
("policy_execution", "Policy Execution"),
("policy_exception", "Policy Exception"),
("property_mapping_exception", "Property Mapping Exception"),
("system_task_execution", "System Task Execution"),
("system_task_exception", "System Task Exception"),
("system_exception", "System Exception"),
("configuration_error", "Configuration Error"),
("configuration_warning", "Configuration Warning"),
("model_created", "Model Created"),
("model_updated", "Model Updated"),
("model_deleted", "Model Deleted"),
("email_sent", "Email Sent"),
("update_available", "Update Available"),
("export_ready", "Export Ready"),
("review_initiated", "Review Initiated"),
("review_overdue", "Review Overdue"),
("review_attested", "Review Attested"),
("review_completed", "Review Completed"),
("custom_", "Custom Prefix"),
]
),
),
]
+5
View File
@@ -123,6 +123,11 @@ class EventAction(models.TextChoices):
EXPORT_READY = "export_ready"
REVIEW_INITIATED = "review_initiated"
REVIEW_OVERDUE = "review_overdue"
REVIEW_ATTESTED = "review_attested"
REVIEW_COMPLETED = "review_completed"
CUSTOM_PREFIX = "custom_"
@@ -0,0 +1,57 @@
# Generated by Django 5.2.11 on 2026-02-04 18:28
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("authentik_policies_event_matcher", "0025_alter_eventmatcherpolicy_action"),
]
operations = [
migrations.AlterField(
model_name="eventmatcherpolicy",
name="action",
field=models.TextField(
choices=[
("login", "Login"),
("login_failed", "Login Failed"),
("logout", "Logout"),
("user_write", "User Write"),
("suspicious_request", "Suspicious Request"),
("password_set", "Password Set"),
("secret_view", "Secret View"),
("secret_rotate", "Secret Rotate"),
("invitation_used", "Invite Used"),
("authorize_application", "Authorize Application"),
("source_linked", "Source Linked"),
("impersonation_started", "Impersonation Started"),
("impersonation_ended", "Impersonation Ended"),
("flow_execution", "Flow Execution"),
("policy_execution", "Policy Execution"),
("policy_exception", "Policy Exception"),
("property_mapping_exception", "Property Mapping Exception"),
("system_task_execution", "System Task Execution"),
("system_task_exception", "System Task Exception"),
("system_exception", "System Exception"),
("configuration_error", "Configuration Error"),
("configuration_warning", "Configuration Warning"),
("model_created", "Model Created"),
("model_updated", "Model Updated"),
("model_deleted", "Model Deleted"),
("email_sent", "Email Sent"),
("update_available", "Update Available"),
("export_ready", "Export Ready"),
("review_initiated", "Review Initiated"),
("review_overdue", "Review Overdue"),
("review_attested", "Review Attested"),
("review_completed", "Review Completed"),
("custom_", "Custom Prefix"),
],
default=None,
help_text="Match created events with this action type. When left empty, all action types will be matched.",
null=True,
),
),
]
+346
View File
@@ -696,6 +696,126 @@
}
}
},
{
"type": "object",
"required": [
"model",
"identifiers"
],
"properties": {
"model": {
"const": "authentik_lifecycle.lifecycleiteration"
},
"id": {
"type": "string"
},
"state": {
"type": "string",
"enum": [
"absent",
"created",
"must_created",
"present"
],
"default": "present"
},
"conditions": {
"type": "array",
"items": {
"type": "boolean"
}
},
"permissions": {
"$ref": "#/$defs/model_authentik_lifecycle.lifecycleiteration_permissions"
},
"attrs": {
"$ref": "#/$defs/model_authentik_lifecycle.lifecycleiteration"
},
"identifiers": {
"$ref": "#/$defs/model_authentik_lifecycle.lifecycleiteration"
}
}
},
{
"type": "object",
"required": [
"model",
"identifiers"
],
"properties": {
"model": {
"const": "authentik_lifecycle.lifecyclerule"
},
"id": {
"type": "string"
},
"state": {
"type": "string",
"enum": [
"absent",
"created",
"must_created",
"present"
],
"default": "present"
},
"conditions": {
"type": "array",
"items": {
"type": "boolean"
}
},
"permissions": {
"$ref": "#/$defs/model_authentik_lifecycle.lifecyclerule_permissions"
},
"attrs": {
"$ref": "#/$defs/model_authentik_lifecycle.lifecyclerule"
},
"identifiers": {
"$ref": "#/$defs/model_authentik_lifecycle.lifecyclerule"
}
}
},
{
"type": "object",
"required": [
"model",
"identifiers"
],
"properties": {
"model": {
"const": "authentik_lifecycle.review"
},
"id": {
"type": "string"
},
"state": {
"type": "string",
"enum": [
"absent",
"created",
"must_created",
"present"
],
"default": "present"
},
"conditions": {
"type": "array",
"items": {
"type": "boolean"
}
},
"permissions": {
"$ref": "#/$defs/model_authentik_lifecycle.review_permissions"
},
"attrs": {
"$ref": "#/$defs/model_authentik_lifecycle.review"
},
"identifiers": {
"$ref": "#/$defs/model_authentik_lifecycle.review"
}
}
},
{
"type": "object",
"required": [
@@ -5562,6 +5682,18 @@
"authentik_flows.view_flowstagebinding",
"authentik_flows.view_flowtoken",
"authentik_flows.view_stage",
"authentik_lifecycle.add_lifecycleiteration",
"authentik_lifecycle.add_lifecyclerule",
"authentik_lifecycle.add_review",
"authentik_lifecycle.change_lifecycleiteration",
"authentik_lifecycle.change_lifecyclerule",
"authentik_lifecycle.change_review",
"authentik_lifecycle.delete_lifecycleiteration",
"authentik_lifecycle.delete_lifecyclerule",
"authentik_lifecycle.delete_review",
"authentik_lifecycle.view_lifecycleiteration",
"authentik_lifecycle.view_lifecyclerule",
"authentik_lifecycle.view_review",
"authentik_outposts.add_dockerserviceconnection",
"authentik_outposts.add_kubernetesserviceconnection",
"authentik_outposts.add_outpost",
@@ -6638,6 +6770,192 @@
}
}
},
"model_authentik_lifecycle.lifecycleiteration": {
"type": "object",
"properties": {
"content_type": {
"type": "string",
"enum": [
"authentik_core.application",
"authentik_core.group",
"authentik_rbac.role"
],
"title": "Content type"
}
},
"required": []
},
"model_authentik_lifecycle.lifecycleiteration_permissions": {
"type": "array",
"items": {
"type": "object",
"required": [
"permission"
],
"properties": {
"permission": {
"type": "string",
"enum": [
"add_lifecycleiteration",
"change_lifecycleiteration",
"delete_lifecycleiteration",
"view_lifecycleiteration"
]
},
"user": {
"type": "integer"
},
"role": {
"type": "string"
}
}
}
},
"model_authentik_lifecycle.lifecyclerule": {
"type": "object",
"properties": {
"name": {
"type": "string",
"minLength": 1,
"title": "Name"
},
"content_type": {
"type": "string",
"enum": [
"authentik_core.application",
"authentik_core.group",
"authentik_rbac.role"
],
"title": "Content type"
},
"object_id": {
"type": [
"string",
"null"
],
"minLength": 1,
"title": "Object id"
},
"interval": {
"type": "string",
"minLength": 1,
"title": "Interval"
},
"grace_period": {
"type": "string",
"minLength": 1,
"title": "Grace period"
},
"reviewer_groups": {
"type": "array",
"items": {
"type": "string",
"format": "uuid"
},
"title": "Reviewer groups"
},
"min_reviewers": {
"type": "integer",
"minimum": 0,
"maximum": 32767,
"title": "Min reviewers"
},
"min_reviewers_is_per_group": {
"type": "boolean",
"title": "Min reviewers is per group"
},
"reviewers": {
"type": "array",
"items": {
"type": "string",
"pattern": "^[-a-zA-Z0-9_]+$"
},
"title": "Reviewers"
},
"notification_transports": {
"type": "array",
"items": {
"type": "string",
"format": "uuid",
"description": "Select which transports should be used to notify the reviewers. If none are selected, the notification will only be shown in the authentik UI."
},
"title": "Notification transports",
"description": "Select which transports should be used to notify the reviewers. If none are selected, the notification will only be shown in the authentik UI."
}
},
"required": []
},
"model_authentik_lifecycle.lifecyclerule_permissions": {
"type": "array",
"items": {
"type": "object",
"required": [
"permission"
],
"properties": {
"permission": {
"type": "string",
"enum": [
"add_lifecyclerule",
"change_lifecyclerule",
"delete_lifecyclerule",
"view_lifecyclerule"
]
},
"user": {
"type": "integer"
},
"role": {
"type": "string"
}
}
}
},
"model_authentik_lifecycle.review": {
"type": "object",
"properties": {
"iteration": {
"type": "string",
"format": "uuid",
"title": "Iteration"
},
"note": {
"type": [
"string",
"null"
],
"minLength": 1,
"title": "Note"
}
},
"required": []
},
"model_authentik_lifecycle.review_permissions": {
"type": "array",
"items": {
"type": "object",
"required": [
"permission"
],
"properties": {
"permission": {
"type": "string",
"enum": [
"add_review",
"change_review",
"delete_review",
"view_review"
]
},
"user": {
"type": "integer"
},
"role": {
"type": "string"
}
}
}
},
"model_authentik_enterprise.license": {
"type": "object",
"properties": {
@@ -7535,6 +7853,10 @@
"email_sent",
"update_available",
"export_ready",
"review_initiated",
"review_overdue",
"review_attested",
"review_completed",
"custom_"
],
"title": "Action"
@@ -7653,6 +7975,10 @@
"email_sent",
"update_available",
"export_ready",
"review_initiated",
"review_overdue",
"review_attested",
"review_completed",
"custom_"
],
"title": "Action"
@@ -8402,6 +8728,10 @@
"email_sent",
"update_available",
"export_ready",
"review_initiated",
"review_overdue",
"review_attested",
"review_completed",
"custom_"
],
"title": "Action",
@@ -8489,6 +8819,7 @@
"authentik.enterprise.audit",
"authentik.enterprise.endpoints.connectors.agent",
"authentik.enterprise.endpoints.connectors.fleet",
"authentik.enterprise.lifecycle",
"authentik.enterprise.policies.unique_password",
"authentik.enterprise.providers.google_workspace",
"authentik.enterprise.providers.microsoft_entra",
@@ -8618,6 +8949,9 @@
"authentik_brands.brand",
"authentik_blueprints.blueprintinstance",
"authentik_endpoints_connectors_fleet.fleetconnector",
"authentik_lifecycle.lifecyclerule",
"authentik_lifecycle.lifecycleiteration",
"authentik_lifecycle.review",
"authentik_policies_unique_password.uniquepasswordpolicy",
"authentik_providers_google_workspace.googleworkspaceprovider",
"authentik_providers_google_workspace.googleworkspaceprovidermapping",
@@ -10936,6 +11270,18 @@
"authentik_flows.view_flowstagebinding",
"authentik_flows.view_flowtoken",
"authentik_flows.view_stage",
"authentik_lifecycle.add_lifecycleiteration",
"authentik_lifecycle.add_lifecyclerule",
"authentik_lifecycle.add_review",
"authentik_lifecycle.change_lifecycleiteration",
"authentik_lifecycle.change_lifecyclerule",
"authentik_lifecycle.change_review",
"authentik_lifecycle.delete_lifecycleiteration",
"authentik_lifecycle.delete_lifecyclerule",
"authentik_lifecycle.delete_review",
"authentik_lifecycle.view_lifecycleiteration",
"authentik_lifecycle.view_lifecyclerule",
"authentik_lifecycle.view_review",
"authentik_outposts.add_dockerserviceconnection",
"authentik_outposts.add_kubernetesserviceconnection",
"authentik_outposts.add_outpost",
+682 -11
View File
@@ -6932,6 +6932,10 @@ paths:
- policy_exception
- policy_execution
- property_mapping_exception
- review_attested
- review_completed
- review_initiated
- review_overdue
- secret_rotate
- secret_view
- source_linked
@@ -7194,6 +7198,10 @@ paths:
- policy_exception
- policy_execution
- property_mapping_exception
- review_attested
- review_completed
- review_initiated
- review_overdue
- secret_rotate
- secret_view
- source_linked
@@ -7322,6 +7330,10 @@ paths:
- policy_exception
- policy_execution
- property_mapping_exception
- review_attested
- review_completed
- review_initiated
- review_overdue
- secret_rotate
- secret_view
- source_linked
@@ -8747,6 +8759,282 @@ paths:
description: ''
'403':
$ref: '#/components/responses/GenericErrorResponse'
/lifecycle/iterations/:
post:
operationId: lifecycle_iterations_create
description: |-
Mixin to validate that a valid enterprise license
exists before allowing to save the object
tags:
- lifecycle
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/LifecycleIterationRequest'
required: true
security:
- authentik: []
responses:
'201':
content:
application/json:
schema:
$ref: '#/components/schemas/LifecycleIteration'
description: ''
'400':
$ref: '#/components/responses/ValidationErrorResponse'
'403':
$ref: '#/components/responses/GenericErrorResponse'
/lifecycle/iterations/latest/{content_type}/{object_id}/:
get:
operationId: lifecycle_iterations_latest_retrieve
description: |-
Mixin to validate that a valid enterprise license
exists before allowing to save the object
parameters:
- in: path
name: content_type
schema:
type: string
pattern: ^[^/]+$
required: true
- in: path
name: object_id
schema:
type: string
pattern: ^[^/]+$
required: true
tags:
- lifecycle
security:
- authentik: []
responses:
'200':
content:
application/json:
schema:
$ref: '#/components/schemas/LifecycleIteration'
description: ''
'400':
$ref: '#/components/responses/ValidationErrorResponse'
'403':
$ref: '#/components/responses/GenericErrorResponse'
/lifecycle/iterations/open/:
get:
operationId: lifecycle_iterations_list_open
description: |-
Mixin to validate that a valid enterprise license
exists before allowing to save the object
parameters:
- $ref: '#/components/parameters/QueryPaginationOrdering'
- $ref: '#/components/parameters/QueryPaginationPage'
- $ref: '#/components/parameters/QueryPaginationPageSize'
- $ref: '#/components/parameters/QuerySearch'
- in: query
name: user_is_reviewer
schema:
type: boolean
tags:
- lifecycle
security:
- authentik: []
responses:
'200':
content:
application/json:
schema:
$ref: '#/components/schemas/PaginatedLifecycleIterationList'
description: ''
'400':
$ref: '#/components/responses/ValidationErrorResponse'
'403':
$ref: '#/components/responses/GenericErrorResponse'
/lifecycle/reviews/:
post:
operationId: lifecycle_reviews_create
description: |-
Mixin to validate that a valid enterprise license
exists before allowing to save the object
tags:
- lifecycle
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/ReviewRequest'
required: true
security:
- authentik: []
responses:
'201':
content:
application/json:
schema:
$ref: '#/components/schemas/Review'
description: ''
'400':
$ref: '#/components/responses/ValidationErrorResponse'
'403':
$ref: '#/components/responses/GenericErrorResponse'
/lifecycle/rules/:
get:
operationId: lifecycle_rules_list
parameters:
- in: query
name: content_type__model
schema:
type: string
- $ref: '#/components/parameters/QueryPaginationOrdering'
- $ref: '#/components/parameters/QueryPaginationPage'
- $ref: '#/components/parameters/QueryPaginationPageSize'
- $ref: '#/components/parameters/QuerySearch'
tags:
- lifecycle
security:
- authentik: []
responses:
'200':
content:
application/json:
schema:
$ref: '#/components/schemas/PaginatedLifecycleRuleList'
description: ''
'400':
$ref: '#/components/responses/ValidationErrorResponse'
'403':
$ref: '#/components/responses/GenericErrorResponse'
post:
operationId: lifecycle_rules_create
tags:
- lifecycle
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/LifecycleRuleRequest'
required: true
security:
- authentik: []
responses:
'201':
content:
application/json:
schema:
$ref: '#/components/schemas/LifecycleRule'
description: ''
'400':
$ref: '#/components/responses/ValidationErrorResponse'
'403':
$ref: '#/components/responses/GenericErrorResponse'
/lifecycle/rules/{id}/:
get:
operationId: lifecycle_rules_retrieve
parameters:
- in: path
name: id
schema:
type: string
format: uuid
description: A UUID string identifying this lifecycle rule.
required: true
tags:
- lifecycle
security:
- authentik: []
responses:
'200':
content:
application/json:
schema:
$ref: '#/components/schemas/LifecycleRule'
description: ''
'400':
$ref: '#/components/responses/ValidationErrorResponse'
'403':
$ref: '#/components/responses/GenericErrorResponse'
put:
operationId: lifecycle_rules_update
parameters:
- in: path
name: id
schema:
type: string
format: uuid
description: A UUID string identifying this lifecycle rule.
required: true
tags:
- lifecycle
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/LifecycleRuleRequest'
required: true
security:
- authentik: []
responses:
'200':
content:
application/json:
schema:
$ref: '#/components/schemas/LifecycleRule'
description: ''
'400':
$ref: '#/components/responses/ValidationErrorResponse'
'403':
$ref: '#/components/responses/GenericErrorResponse'
patch:
operationId: lifecycle_rules_partial_update
parameters:
- in: path
name: id
schema:
type: string
format: uuid
description: A UUID string identifying this lifecycle rule.
required: true
tags:
- lifecycle
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/PatchedLifecycleRuleRequest'
security:
- authentik: []
responses:
'200':
content:
application/json:
schema:
$ref: '#/components/schemas/LifecycleRule'
description: ''
'400':
$ref: '#/components/responses/ValidationErrorResponse'
'403':
$ref: '#/components/responses/GenericErrorResponse'
delete:
operationId: lifecycle_rules_destroy
parameters:
- in: path
name: id
schema:
type: string
format: uuid
description: A UUID string identifying this lifecycle rule.
required: true
tags:
- lifecycle
security:
- authentik: []
responses:
'204':
description: No response body
'400':
$ref: '#/components/responses/ValidationErrorResponse'
'403':
$ref: '#/components/responses/GenericErrorResponse'
/managed/blueprints/:
get:
operationId: managed_blueprints_list
@@ -10983,6 +11271,10 @@ paths:
- policy_exception
- policy_execution
- property_mapping_exception
- review_attested
- review_completed
- review_initiated
- review_overdue
- secret_rotate
- secret_view
- source_linked
@@ -20533,6 +20825,9 @@ paths:
- authentik_events.notificationwebhookmapping
- authentik_flows.flow
- authentik_flows.flowstagebinding
- authentik_lifecycle.lifecycleiteration
- authentik_lifecycle.lifecyclerule
- authentik_lifecycle.review
- authentik_outposts.dockerserviceconnection
- authentik_outposts.kubernetesserviceconnection
- authentik_outposts.outpost
@@ -33753,6 +34048,7 @@ components:
- authentik.enterprise.audit
- authentik.enterprise.endpoints.connectors.agent
- authentik.enterprise.endpoints.connectors.fleet
- authentik.enterprise.lifecycle
- authentik.enterprise.policies.unique_password
- authentik.enterprise.providers.google_workspace
- authentik.enterprise.providers.microsoft_entra
@@ -36129,6 +36425,12 @@ components:
- id
- model
- verbose_name_plural
ContentTypeEnum:
enum:
- authentik_core.application
- authentik_core.group
- authentik_rbac.role
type: string
ContextualFlowInfo:
type: object
description: Contextual flow information for a challenge
@@ -38127,6 +38429,10 @@ components:
- email_sent
- update_available
- export_ready
- review_initiated
- review_overdue
- review_attested
- review_completed
- custom_
type: string
EventMatcherPolicy:
@@ -41811,6 +42117,211 @@ components:
- limit_exceeded_user
- read_only
type: string
LifecycleIteration:
type: object
description: |-
Mixin to validate that a valid enterprise license
exists before allowing to save the object
properties:
id:
type: string
format: uuid
readOnly: true
content_type:
$ref: '#/components/schemas/ContentTypeEnum'
object_id:
type: string
readOnly: true
object_verbose:
type: string
readOnly: true
object_admin_url:
type: string
readOnly: true
state:
allOf:
- $ref: '#/components/schemas/LifecycleIterationStateEnum'
readOnly: true
opened_on:
type: string
format: date
readOnly: true
grace_period_end:
type: string
format: date
readOnly: true
next_review_date:
type: string
format: date
readOnly: true
reviews:
type: array
items:
$ref: '#/components/schemas/Review'
readOnly: true
user_can_review:
type: boolean
readOnly: true
reviewer_groups:
type: array
items:
$ref: '#/components/schemas/ReviewerGroup'
readOnly: true
min_reviewers:
type: integer
readOnly: true
reviewers:
type: array
items:
$ref: '#/components/schemas/ReviewerUser'
readOnly: true
required:
- content_type
- grace_period_end
- id
- min_reviewers
- next_review_date
- object_admin_url
- object_id
- object_verbose
- opened_on
- reviewer_groups
- reviewers
- reviews
- state
- user_can_review
LifecycleIterationRequest:
type: object
description: |-
Mixin to validate that a valid enterprise license
exists before allowing to save the object
properties:
content_type:
$ref: '#/components/schemas/ContentTypeEnum'
required:
- content_type
LifecycleIterationStateEnum:
enum:
- REVIEWED
- PENDING
- OVERDUE
- CANCELED
type: string
LifecycleRule:
type: object
description: |-
Mixin to validate that a valid enterprise license
exists before allowing to save the object
properties:
id:
type: string
format: uuid
readOnly: true
name:
type: string
content_type:
$ref: '#/components/schemas/ContentTypeEnum'
object_id:
type: string
nullable: true
interval:
type: string
grace_period:
type: string
reviewer_groups:
type: array
items:
type: string
format: uuid
reviewer_groups_obj:
type: array
items:
$ref: '#/components/schemas/ReviewerGroup'
readOnly: true
min_reviewers:
type: integer
maximum: 32767
minimum: 0
min_reviewers_is_per_group:
type: boolean
reviewers:
type: array
items:
type: string
format: uuid
reviewers_obj:
type: array
items:
$ref: '#/components/schemas/ReviewerUser'
readOnly: true
notification_transports:
type: array
items:
type: string
format: uuid
description: Select which transports should be used to notify the reviewers.
If none are selected, the notification will only be shown in the authentik
UI.
target_verbose:
type: string
readOnly: true
required:
- content_type
- id
- name
- reviewer_groups_obj
- reviewers
- reviewers_obj
- target_verbose
LifecycleRuleRequest:
type: object
description: |-
Mixin to validate that a valid enterprise license
exists before allowing to save the object
properties:
name:
type: string
minLength: 1
content_type:
$ref: '#/components/schemas/ContentTypeEnum'
object_id:
type: string
nullable: true
minLength: 1
interval:
type: string
minLength: 1
grace_period:
type: string
minLength: 1
reviewer_groups:
type: array
items:
type: string
format: uuid
min_reviewers:
type: integer
maximum: 32767
minimum: 0
min_reviewers_is_per_group:
type: boolean
reviewers:
type: array
items:
type: string
format: uuid
notification_transports:
type: array
items:
type: string
format: uuid
description: Select which transports should be used to notify the reviewers.
If none are selected, the notification will only be shown in the authentik
UI.
required:
- content_type
- name
- reviewers
Link:
type: object
description: Returns a single link
@@ -42332,6 +42843,9 @@ components:
- authentik_brands.brand
- authentik_blueprints.blueprintinstance
- authentik_endpoints_connectors_fleet.fleetconnector
- authentik_lifecycle.lifecyclerule
- authentik_lifecycle.lifecycleiteration
- authentik_lifecycle.review
- authentik_policies_unique_password.uniquepasswordpolicy
- authentik_providers_google_workspace.googleworkspaceprovider
- authentik_providers_google_workspace.googleworkspaceprovidermapping
@@ -44786,6 +45300,36 @@ components:
- pagination
- results
- autocomplete
PaginatedLifecycleIterationList:
type: object
properties:
pagination:
$ref: '#/components/schemas/Pagination'
results:
type: array
items:
$ref: '#/components/schemas/LifecycleIteration'
autocomplete:
$ref: '#/components/schemas/Autocomplete'
required:
- pagination
- results
- autocomplete
PaginatedLifecycleRuleList:
type: object
properties:
pagination:
$ref: '#/components/schemas/Pagination'
results:
type: array
items:
$ref: '#/components/schemas/LifecycleRule'
autocomplete:
$ref: '#/components/schemas/Autocomplete'
required:
- pagination
- results
- autocomplete
PaginatedMicrosoftEntraProviderGroupList:
type: object
properties:
@@ -48193,6 +48737,51 @@ components:
key:
type: string
minLength: 1
PatchedLifecycleRuleRequest:
type: object
description: |-
Mixin to validate that a valid enterprise license
exists before allowing to save the object
properties:
name:
type: string
minLength: 1
content_type:
$ref: '#/components/schemas/ContentTypeEnum'
object_id:
type: string
nullable: true
minLength: 1
interval:
type: string
minLength: 1
grace_period:
type: string
minLength: 1
reviewer_groups:
type: array
items:
type: string
format: uuid
min_reviewers:
type: integer
maximum: 32767
minimum: 0
min_reviewers_is_per_group:
type: boolean
reviewers:
type: array
items:
type: string
format: uuid
notification_transports:
type: array
items:
type: string
format: uuid
description: Select which transports should be used to notify the reviewers.
If none are selected, the notification will only be shown in the authentik
UI.
PatchedMicrosoftEntraProviderMappingRequest:
type: object
description: MicrosoftEntraProviderMapping Serializer
@@ -52373,6 +52962,88 @@ components:
- preferred
- required
type: string
Review:
type: object
description: |-
Mixin to validate that a valid enterprise license
exists before allowing to save the object
properties:
id:
type: string
format: uuid
readOnly: true
iteration:
type: string
format: uuid
reviewer:
allOf:
- $ref: '#/components/schemas/ReviewerUser'
readOnly: true
timestamp:
type: string
format: date-time
readOnly: true
note:
type: string
nullable: true
required:
- id
- iteration
- reviewer
- timestamp
ReviewRequest:
type: object
description: |-
Mixin to validate that a valid enterprise license
exists before allowing to save the object
properties:
iteration:
type: string
format: uuid
note:
type: string
nullable: true
minLength: 1
required:
- iteration
ReviewerGroup:
type: object
properties:
pk:
type: string
format: uuid
readOnly: true
title: Group uuid
name:
type: string
required:
- name
- pk
ReviewerUser:
type: object
properties:
pk:
type: integer
readOnly: true
title: ID
uuid:
type: string
format: uuid
readOnly: true
username:
type: string
description: Required. 150 characters or fewer. Letters, digits and @/./+/-/_
only.
pattern: ^[\w.@+-]+$
maxLength: 150
name:
type: string
description: User's display name.
required:
- name
- pk
- username
- uuid
Role:
type: object
description: Role serializer
@@ -54846,16 +55517,6 @@ components:
- required
- sub_text
- type
StateEnum:
enum:
- queued
- consumed
- preprocess
- running
- postprocess
- rejected
- done
type: string
StaticDevice:
type: object
description: Serializer for static authenticator devices
@@ -55081,7 +55742,7 @@ components:
description: Dramatiq actor name
state:
allOf:
- $ref: '#/components/schemas/StateEnum'
- $ref: '#/components/schemas/TaskStateEnum'
description: Task status
mtime:
type: string
@@ -55149,6 +55810,16 @@ components:
- warning
- error
type: string
TaskStateEnum:
enum:
- queued
- consumed
- preprocess
- running
- postprocess
- rejected
- done
type: string
TelegramAuthRequest:
type: object
properties:
@@ -73,6 +73,8 @@ export const createAdminSidebarEntries = (): readonly SidebarEntry[] => [
["/events/log", msg("Logs"), [`^/events/log/(?<id>${UUID_REGEX})$`]],
["/events/rules", msg("Notification Rules")],
["/events/transports", msg("Notification Transports")],
["/events/lifecycle-rules", msg("Lifecycle Rules"), {enterprise:true}],
["/events/lifecycle-reviews", msg("Reviews"), {enterprise:true}],
["/events/exports", msg("Data Exports"), {enterprise:true}]]
],
[null, msg("Customization"), null, [
+8
View File
@@ -157,6 +157,14 @@ export const ROUTES: Route[] = [
await import("./events/DataExportListPage");
return html`<ak-data-export-list></ak-data-export-list>`;
}),
new Route(new RegExp("^/events/lifecycle-rules$"), async () => {
await import("#admin/lifecycle/LifecycleRuleListPage");
return html`<ak-lifecycle-rule-list></ak-lifecycle-rule-list>`;
}),
new Route(new RegExp("^/events/lifecycle-reviews"), async () => {
await import("#admin/lifecycle/ReviewListPage");
return html`<ak-review-list></ak-review-list>`;
}),
new Route(new RegExp("^/outpost/outposts$"), async () => {
await import("#admin/outposts/OutpostListPage");
return html`<ak-outpost-list></ak-outpost-list>`;
@@ -4,6 +4,7 @@ import "#admin/applications/ApplicationForm";
import "#admin/applications/entitlements/ApplicationEntitlementPage";
import "#admin/policies/BoundPoliciesList";
import "#admin/rbac/ObjectPermissionsPage";
import "#admin/lifecycle/ObjectLifecyclePage";
import "#components/events/ObjectChangelog";
import "#elements/AppIcon";
import "#elements/EmptyState";
@@ -14,11 +15,13 @@ import { DEFAULT_CONFIG } from "#common/api/config";
import { APIError, parseAPIResponseError, pluckErrorDetail } from "#common/errors/network";
import { AKElement } from "#elements/Base";
import { WithLicenseSummary } from "#elements/mixins/license";
import { setPageDetails } from "#components/ak-page-navbar";
import {
Application,
ContentTypeEnum,
CoreApi,
OutpostsApi,
RbacPermissionsAssignedByRolesListModelEnum,
@@ -39,7 +42,7 @@ import PFFlex from "@patternfly/patternfly/layouts/Flex/flex.css";
import PFGrid from "@patternfly/patternfly/layouts/Grid/grid.css";
@customElement("ak-application-view")
export class ApplicationViewPage extends AKElement {
export class ApplicationViewPage extends WithLicenseSummary(AKElement) {
static styles: CSSResult[] = [
PFList,
PFBanner,
@@ -409,6 +412,18 @@ export class ApplicationViewPage extends AKElement {
model=${RbacPermissionsAssignedByRolesListModelEnum.AuthentikCoreApplication}
objectPk=${this.application.pk}
></ak-rbac-object-permission-page>
${this.hasEnterpriseLicense
? html`<ak-object-lifecycle-page
class="pf-c-page__main-section pf-m-no-padding-mobile"
role="tabpanel"
tabindex="0"
slot="page-lifecycle"
id="page-lifecycle"
aria-label=${msg("Lifecycle")}
model=${ContentTypeEnum.AuthentikCoreApplication}
object-pk=${this.application.pk}
></ak-object-lifecycle-page>`
: nothing}
</ak-tabs>
</main>`;
}
+21 -2
View File
@@ -3,6 +3,7 @@ import "#admin/groups/RelatedUserList";
import "#admin/rbac/ObjectPermissionsPage";
import "#admin/roles/RelatedRoleList";
import "#components/ak-object-attributes-card";
import "#admin/lifecycle/ObjectLifecyclePage";
import "#components/ak-status-label";
import "#components/events/ObjectChangelog";
import "#elements/CodeMirror";
@@ -16,11 +17,17 @@ import { DEFAULT_CONFIG } from "#common/api/config";
import { EVENT_REFRESH } from "#common/constants";
import { AKElement } from "#elements/Base";
import { WithLicenseSummary } from "#elements/mixins/license";
import { SlottedTemplateResult } from "#elements/types";
import { setPageDetails } from "#components/ak-page-navbar";
import { CoreApi, Group, RbacPermissionsAssignedByRolesListModelEnum } from "@goauthentik/api";
import {
ContentTypeEnum,
CoreApi,
Group,
RbacPermissionsAssignedByRolesListModelEnum,
} from "@goauthentik/api";
import { msg, str } from "@lit/localize";
import { CSSResult, html, nothing, PropertyValues, TemplateResult } from "lit";
@@ -37,7 +44,7 @@ import PFDisplay from "@patternfly/patternfly/utilities/Display/display.css";
import PFSizing from "@patternfly/patternfly/utilities/Sizing/sizing.css";
@customElement("ak-group-view")
export class GroupViewPage extends AKElement {
export class GroupViewPage extends WithLicenseSummary(AKElement) {
@property({ type: String })
set groupId(id: string) {
new CoreApi(DEFAULT_CONFIG)
@@ -256,6 +263,18 @@ export class GroupViewPage extends AKElement {
model=${RbacPermissionsAssignedByRolesListModelEnum.AuthentikCoreGroup}
objectPk=${this.group.pk}
></ak-rbac-object-permission-page>
${this.hasEnterpriseLicense
? html`<ak-object-lifecycle-page
class="pf-c-page__main-section pf-m-no-padding-mobile"
role="tabpanel"
tabindex="0"
slot="page-lifecycle"
id="page-lifecycle"
aria-label="${msg("Lifecycle")}"
model=${ContentTypeEnum.AuthentikCoreGroup}
object-pk=${this.group.pk}
></ak-object-lifecycle-page>`
: nothing}
</ak-tabs>
</main>`;
}
@@ -0,0 +1,27 @@
import { AKElement } from "#elements/Base";
import { ObjectLifecyclePage } from "#admin/lifecycle/ObjectLifecyclePage";
import { msg } from "@lit/localize";
import { html, TemplateResult } from "lit";
import { customElement } from "lit/decorators.js";
import PFBanner from "@patternfly/patternfly/components/Banner/banner.css";
@customElement("ak-lifecycle-preview-banner")
export class LifecyclePreviewBanner extends AKElement {
static styles = [PFBanner];
public render(): TemplateResult {
return html`<div class="pf-c-banner pf-m-info">
${msg("Object Lifecycle Management is in preview.")}
<a href="mailto:hello+feature/lifecycle@goauthentik.io">${msg("Send us feedback!")}</a>
</div>`;
}
}
declare global {
interface HTMLElementTagNameMap {
"ak-lifecycle-preview-banner": ObjectLifecyclePage;
}
}
@@ -0,0 +1,343 @@
import "#elements/ak-dual-select/ak-dual-select-dynamic-selected-provider";
import "#elements/forms/HorizontalFormElement";
import "#elements/forms/Radio";
import "#elements/forms/SearchSelect/index";
import "#elements/ak-list-select/ak-list-select";
import "#elements/utils/TimeDeltaHelp";
import "#components/ak-text-input";
import "#components/ak-radio-input";
import "#components/ak-number-input";
import "#components/ak-switch-input";
import { DEFAULT_CONFIG } from "#common/api/config";
import { DataProvision, DualSelectPair } from "#elements/ak-dual-select/types";
import { ModelForm } from "#elements/forms/ModelForm";
import { RadioChangeEventDetail, RadioOption } from "#elements/forms/Radio";
import type SearchSelect from "#elements/forms/SearchSelect/SearchSelect";
import { SlottedTemplateResult } from "#elements/types";
import { eventTransportsProvider, eventTransportsSelector } from "#admin/events/RuleFormHelpers";
import {
Application,
ContentTypeEnum,
CoreApi,
Group,
LifecycleApi,
LifecycleRule,
RbacApi,
ReviewerGroup,
ReviewerUser,
Role,
} from "@goauthentik/api";
import { match } from "ts-pattern";
import { msg } from "@lit/localize";
import { html } from "lit";
import { ifDefined } from "lit-html/directives/if-defined.js";
import { customElement, state } from "lit/decorators.js";
import { keyed } from "lit/directives/keyed.js";
import { createRef, ref } from "lit/directives/ref.js";
type TargetObject = Application | Group | Role;
function userToPair(item: ReviewerUser): DualSelectPair {
return [item.uuid, html`<div class="selection-main">${item.name}</div>`, item.name];
}
function groupToPair(item: ReviewerGroup): DualSelectPair {
return [item.pk, html`<div class="selection-main">${item.name}</div>`, item.name];
}
function createContentTypeOptions(): RadioOption<ContentTypeEnum>[] {
return [
{
value: ContentTypeEnum.AuthentikCoreApplication,
label: msg("Application"),
default: true,
},
{
value: ContentTypeEnum.AuthentikCoreGroup,
label: msg("Group"),
},
{
value: ContentTypeEnum.AuthentikRbacRole,
label: msg("Role"),
},
] satisfies RadioOption<ContentTypeEnum>[];
}
function formatContentTypePlaceholder(contentType: ContentTypeEnum): string {
switch (contentType) {
case ContentTypeEnum.AuthentikCoreApplication:
return msg("Select an application...");
case ContentTypeEnum.AuthentikCoreGroup:
return msg("Select a group...");
case ContentTypeEnum.AuthentikRbacRole:
return msg("Select a role...");
case ContentTypeEnum.UnknownDefaultOpenApi:
return msg("Select an object...");
}
}
@customElement("ak-lifecycle-rule-form")
export class LifecycleRuleForm extends ModelForm<LifecycleRule, string> {
#targetSelectRef = createRef<SearchSelect<TargetObject>>();
#reviewerGroupsSelectRef = createRef<SearchSelect<Group>>();
#reviewerUsersSelectRef = createRef<SearchSelect<Group>>();
#coreApi = new CoreApi(DEFAULT_CONFIG);
#lifecycleApi = new LifecycleApi(DEFAULT_CONFIG);
#rbacApi = new RbacApi(DEFAULT_CONFIG);
@state()
protected selectedContentType: ContentTypeEnum = ContentTypeEnum.AuthentikCoreApplication;
protected async loadInstance(pk: string): Promise<LifecycleRule> {
const rule = await this.#lifecycleApi.lifecycleRulesRetrieve({
id: pk,
});
this.selectedContentType = rule.contentType;
return rule;
}
#fetchGroups = (page: number, search?: string): Promise<DataProvision> => {
return this.#coreApi
.coreGroupsList({
page: page,
search: search,
})
.then((results) => {
return {
pagination: results.pagination,
options: results.results.map(groupToPair),
};
});
};
#fetchUsers = (page: number, search?: string): Promise<DataProvision> => {
return this.#coreApi
.coreUsersList({
page: page,
search: search,
})
.then((results) => {
return {
pagination: results.pagination,
options: results.results.map(userToPair),
};
});
};
protected override async send(data: LifecycleRule): Promise<LifecycleRule> {
if (this.instance) {
return this.#lifecycleApi.lifecycleRulesUpdate({
id: this.instance.id,
lifecycleRuleRequest: data,
});
}
return this.#lifecycleApi.lifecycleRulesCreate({
lifecycleRuleRequest: data,
});
}
protected override serialize(): LifecycleRule | null {
const result = super.serialize();
if (!result) {
return null;
}
if (!result.objectId) {
result.objectId = null;
}
return result;
}
#loadObjects = async (query?: string): Promise<TargetObject[]> => {
const promise = match(this.selectedContentType)
.with(ContentTypeEnum.AuthentikCoreApplication, () =>
this.#coreApi.coreApplicationsList({
ordering: "name",
search: query,
superuserFullList: true,
}),
)
.with(ContentTypeEnum.AuthentikCoreGroup, () =>
this.#coreApi.coreGroupsList({
ordering: "name",
search: query,
}),
)
.with(ContentTypeEnum.AuthentikRbacRole, () =>
this.#rbacApi.rbacRolesList({
ordering: "name",
search: query,
}),
)
.otherwise(() => null);
if (!promise) {
return [];
}
return promise.then((response) => response.results);
};
#contentTypeChangeListener = async (
event: CustomEvent<RadioChangeEventDetail<ContentTypeEnum>>,
): Promise<void> => {
this.selectedContentType = event.detail.value;
};
protected renderForm(): SlottedTemplateResult {
return html`<ak-text-input
label=${msg("Rule Name")}
name="name"
required
value="${ifDefined(this.instance?.name)}"
placeholder=${msg("Type a name for this lifecycle rule...")}
></ak-text-input>
${this.renderContentTypeOptions()} ${this.renderTargetSelection()}
<ak-text-input
label=${msg("Interval")}
name="interval"
required
value="${this.instance?.interval || "days=60"}"
input-hint="code"
help=${msg("The interval between opening new reviews for matching objects.")}
.bighelp=${html`<ak-utils-time-delta-help></ak-utils-time-delta-help>`}
></ak-text-input>
<ak-text-input
label=${msg("Grace period")}
name="gracePeriod"
required
value="${this.instance?.gracePeriod || "days=30"}"
input-hint="code"
help=${msg("The duration of time before an open review is considered overdue.")}
.bighelp=${html`<ak-utils-time-delta-help></ak-utils-time-delta-help>`}
></ak-text-input>
<ak-form-element-horizontal label=${msg("Reviewer groups")} name="reviewerGroups">
${this.renderReviewerGroupsSelection()}
</ak-form-element-horizontal>
<ak-number-input
label=${msg("Min reviewers")}
min=${1}
name="minReviewers"
value="${this.instance?.minReviewers ?? 1}"
help=${msg(
"Number of users from the selected reviewer groups that must approve the review.",
)}
></ak-number-input>
<ak-switch-input
name="minReviewersIsPerGroup"
?checked=${this.instance?.minReviewersIsPerGroup ?? false}
label=${msg("Min reviewers is per-group")}
help=${msg(
"If checked, approving a review will require at least that many users from _each_ of the selected groups. When disabled, the value is a total across all groups.",
)}
>
</ak-switch-input>
<ak-form-element-horizontal label=${msg("Reviewers")} name="reviewers">
${this.renderReviewerUserSelection()}
</ak-form-element-horizontal>
${this.renderTransportsSelection()} `;
}
protected renderContentTypeOptions(): SlottedTemplateResult {
return html`<ak-radio-input
@change=${this.#contentTypeChangeListener}
label=${msg("Object type")}
name="contentType"
required
.value=${this.instance?.contentType}
.options=${createContentTypeOptions()}
></ak-radio-input>`;
}
protected renderTargetSelection() {
return keyed(
this.selectedContentType,
html`<ak-form-element-horizontal label=${msg("Object")} name="objectId">
<ak-search-select
${ref(this.#targetSelectRef)}
placeholder=${formatContentTypePlaceholder(this.selectedContentType)}
.fetchObjects=${this.#loadObjects}
.renderElement=${(obj: TargetObject) => obj.name}
.value=${(obj?: TargetObject) => obj?.pk}
.selected=${(obj: TargetObject): boolean => {
return obj.pk === this.instance?.objectId;
}}
blankable
></ak-search-select>
<p class="pf-c-form__helper-text">
${msg(
"When set, the rule will apply to the selected individual object. Otherwise, the rule applies to all objects of the selected type.",
)}
</p>
</ak-form-element-horizontal>`,
);
}
protected renderReviewerGroupsSelection(): SlottedTemplateResult {
return html`<ak-dual-select-provider
${ref(this.#reviewerGroupsSelectRef)}
.provider=${this.#fetchGroups}
.selected=${(this.instance?.reviewerGroupsObj ?? []).map(groupToPair)}
available-label=${msg("Available Groups")}
selected-label=${msg("Selected Groups")}
></ak-dual-select-provider>`;
}
protected renderReviewerUserSelection(): SlottedTemplateResult {
return html`<ak-dual-select-provider
${ref(this.#reviewerUsersSelectRef)}
.provider=${this.#fetchUsers}
.selected=${(this.instance?.reviewersObj ?? []).map(userToPair)}
available-label=${msg("Available Users")}
selected-label=${msg("Selected Users")}
></ak-dual-select-provider>
<p class="pf-c-form__helper-text">
${msg(
"A review will require approval from each of the users selected here in addition to group members as per above settings.",
)}
</p>`;
}
protected renderTransportsSelection(): SlottedTemplateResult {
return html`
<ak-form-element-horizontal
label=${msg("Notification transports")}
required
name="notificationTransports"
>
<ak-dual-select-dynamic-selected
.provider=${eventTransportsProvider}
.selector=${eventTransportsSelector(this.instance?.notificationTransports)}
available-label="${msg("Available Transports")}"
selected-label="${msg("Selected Transports")}"
></ak-dual-select-dynamic-selected>
<p class="pf-c-form__helper-text">
${msg("Select which transports should be used to notify the user.")}
</p>
</ak-form-element-horizontal>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"ak-lifecycle-rule-form": LifecycleRuleForm;
}
}
@@ -0,0 +1,148 @@
import "#admin/lifecycle/LifecycleRuleForm";
import "#admin/lifecycle/LifecyclePreviewBanner";
import "#admin/policies/BoundPoliciesList";
import "#admin/rbac/ObjectPermissionModal";
import "#components/ak-status-label";
import "#elements/buttons/SpinnerButton/index";
import "#elements/forms/DeleteBulkForm";
import "#elements/forms/ModalForm";
import "#elements/tasks/TaskList";
import "@patternfly/elements/pf-tooltip/pf-tooltip.js";
import { DEFAULT_CONFIG } from "#common/api/config";
import { PaginatedResponse, TableColumn } from "#elements/table/Table";
import { TablePage } from "#elements/table/TablePage";
import { SlottedTemplateResult } from "#elements/types";
import {
LifecycleApi,
LifecycleRule,
ModelEnum,
RbacPermissionsAssignedByRolesListModelEnum,
} from "@goauthentik/api";
import { msg } from "@lit/localize";
import { html, TemplateResult } from "lit";
import { customElement } from "lit/decorators.js";
@customElement("ak-lifecycle-rule-list")
export class LifecycleRuleListPage extends TablePage<LifecycleRule> {
public override expandable = true;
public override checkbox = true;
public override clearOnRefresh = true;
public pageTitle = msg("Object Lifecycle Rules");
public pageDescription = msg("Schedule periodic reviews for objects in authentik.");
public pageIcon = "pf-icon pf-icon-history";
public override order = "name";
protected override searchEnabled = true;
protected async apiEndpoint(): Promise<PaginatedResponse<LifecycleRule>> {
return new LifecycleApi(DEFAULT_CONFIG).lifecycleRulesList(
await this.defaultEndpointConfig(),
);
}
protected renderSectionBefore(): TemplateResult {
return html`<ak-lifecycle-preview-banner></ak-lifecycle-preview-banner>`;
}
protected columns: TableColumn[] = [
[msg("Name"), "name"],
[msg("Target"), "content_type__model"],
[msg("Interval"), "interval"],
[msg("Grace period"), "grace_period"],
[msg("Actions"), null, msg("Row Actions")],
];
renderToolbarSelected(): TemplateResult {
const disabled = this.selectedElements.length < 1;
return html` <ak-forms-delete-bulk
object-label=${msg("Lifecycle rule(s)")}
.objects=${this.selectedElements}
.delete=${(item: LifecycleRule) => {
if (item.id)
return new LifecycleApi(DEFAULT_CONFIG).lifecycleRulesDestroy({
id: item.id,
});
}}
.metadata=${(item: LifecycleRule) => [
{ key: msg("Target"), value: item.targetVerbose },
]}
>
<button ?disabled=${disabled} slot="trigger" class="pf-c-button pf-m-danger">
${msg("Delete")}
</button>
</ak-forms-delete-bulk>`;
}
row(item: LifecycleRule): SlottedTemplateResult[] {
return [
html`${item.name}`,
html`${item.targetVerbose}`,
html`${item.interval}`,
html`${item.gracePeriod}`,
html` <div>
<ak-forms-modal>
<span slot="submit">${msg("Update")}</span>
<span slot="header">${msg("Update Lifecycle Rule")}</span>
<ak-lifecycle-rule-form
slot="form"
.instancePk=${item.id}
></ak-lifecycle-rule-form>
<button slot="trigger" class="pf-c-button pf-m-plain">
<pf-tooltip position="top" content=${msg("Edit")}>
<i class="fas fa-edit" aria-hidden="true"></i>
</pf-tooltip>
</button>
</ak-forms-modal>
<ak-rbac-object-permission-modal
model=${RbacPermissionsAssignedByRolesListModelEnum.AuthentikLifecycleLifecyclerule}
objectPk=${item.id}
>
</ak-rbac-object-permission-modal>
</div>`,
];
}
renderExpanded(item: LifecycleRule): TemplateResult {
const [appLabel, modelName] = ModelEnum.AuthentikLifecycleLifecyclerule.split(".");
return html`<dl class="pf-c-description-list pf-m-horizontal">
<div class="pf-c-description-list__group">
<dt class="pf-c-description-list__term">
<span class="pf-c-description-list__text">${msg("Tasks")}</span>
</dt>
<dd class="pf-c-description-list__description">
<div class="pf-c-description-list__text">
<ak-task-list
.relObjAppLabel=${appLabel}
.relObjModel=${modelName}
.relObjId="${item.id}"
></ak-task-list>
</div>
</dd>
</div>
</dl>`;
}
renderObjectCreate(): TemplateResult {
return html`
<ak-forms-modal>
<span slot="submit">${msg("Create")}</span>
<span slot="header">${msg("Create Object Lifecycle Rule")}</span>
<ak-lifecycle-rule-form slot="form"></ak-lifecycle-rule-form>
<button slot="trigger" class="pf-c-button pf-m-primary">${msg("Create")}</button>
</ak-forms-modal>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"ak-lifecycle-rule-list": LifecycleRuleListPage;
}
}
@@ -0,0 +1,307 @@
import "#admin/lifecycle/LifecyclePreviewBanner";
import "#components/ak-textarea-input";
import "#elements/forms/ModalForm";
import "#elements/timestamp/ak-timestamp";
import "#admin/lifecycle/ObjectReviewForm";
import { DEFAULT_CONFIG } from "#common/api/config";
import { createPaginatedResponse } from "#common/api/responses";
import { isResponseErrorLike } from "#common/errors/network";
import { PaginatedResponse, Table, TableColumn, Timestamp } from "#elements/table/Table";
import { SlottedTemplateResult } from "#elements/types";
import { ifPreviousValue } from "#elements/utils/properties";
import { LifecycleIterationStatus } from "#admin/lifecycle/utils";
import {
ContentTypeEnum,
LifecycleApi,
LifecycleIteration,
LifecycleIterationStateEnum,
Review,
} from "@goauthentik/api";
import { match, P } from "ts-pattern";
import { msg, str } from "@lit/localize";
import { html, nothing, PropertyValues, TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import PFBanner from "@patternfly/patternfly/components/Banner/banner.css";
import PFCard from "@patternfly/patternfly/components/Card/card.css";
import PFDescriptionList from "@patternfly/patternfly/components/DescriptionList/description-list.css";
import PFFlex from "@patternfly/patternfly/layouts/Flex/flex.css";
import PFGrid from "@patternfly/patternfly/layouts/Grid/grid.css";
@customElement("ak-object-lifecycle-page")
export class ObjectLifecyclePage extends Table<Review> {
static styles = [
// ---
...super.styles,
PFGrid,
PFBanner,
PFCard,
PFFlex,
PFDescriptionList,
];
//#region Public Properties
@property({ type: String })
public model: ContentTypeEnum | null = null;
@property({ attribute: "object-pk", hasChanged: ifPreviousValue, useDefault: true })
public objectPk: string | number | null = null;
public override paginated = false;
//#endregion
//#region Protected Properties
protected override emptyStateMessage = msg("No reviews yet.");
protected columns: TableColumn[] = [
[msg("Reviewed on"), "timestamp"],
[msg("Reviewer"), "reviewer"],
[msg("Note"), "note"],
];
//#region Lifecycle
@state()
protected iteration: LifecycleIteration | null = null;
protected apiEndpoint(): Promise<PaginatedResponse<Review>> {
if (!this.model || !this.objectPk) {
return Promise.resolve(createPaginatedResponse<Review>());
}
return new LifecycleApi(DEFAULT_CONFIG)
.lifecycleIterationsLatestRetrieve({
contentType: this.model,
objectId: String(this.objectPk),
})
.then((iteration) => {
this.iteration = iteration;
return createPaginatedResponse(iteration.reviews);
})
.catch(async (error: unknown) => {
if (isResponseErrorLike(error) && error.response.status === 404) {
this.iteration = null;
return createPaginatedResponse<Review>();
}
throw error;
});
}
protected updated(changedProperties: PropertyValues<this>): void {
super.updated(changedProperties);
if (changedProperties.has("model") || changedProperties.has("objectPk")) {
this.fetch();
}
}
//#endregion
//#region Rendering
//#region Summary Card
protected renderReviewers(): SlottedTemplateResult {
if (!this.iteration) {
return html`<span>${msg("No review iteration found for this object.")}</span>`;
}
const { reviewers, reviewerGroups, minReviewers } = this.iteration;
const result: TemplateResult[] = [];
if (reviewers.length) {
result.push(html`<div>${reviewers.map((u) => u.name).join(", ")}</div>`);
}
const groupList = reviewerGroups.map((g) => g.name).join(", ");
const label =
minReviewers === 1
? reviewerGroups.length === 1
? msg(str`At least ${minReviewers} user from this group: ${groupList}.`)
: msg(str`At least ${minReviewers} user from these groups: ${groupList}.`)
: reviewerGroups.length === 1
? msg(str`At least ${minReviewers} users from this group: ${groupList}.`)
: msg(str`At least ${minReviewers} users from these groups: ${groupList}.`);
result.push(html`<div>${label}</div>`);
return result;
}
protected renderOpenedOn(): SlottedTemplateResult {
return html`<div class="pf-c-description-list__group">
<dt class="pf-c-description-list__term">
<span class="pf-c-description-list__text">${msg("Review opened on")}</span>
</dt>
<dd class="pf-c-description-list__description">
<div class="pf-c-description-list__text">
<ak-timestamp
.timestamp=${this.iteration?.openedOn}
.elapsed=${false}
dateonly
datetime
></ak-timestamp>
</div>
</dd>
</div>`;
}
protected renderGracePeriodTill(): SlottedTemplateResult {
return html`<div class="pf-c-description-list__group">
<dt class="pf-c-description-list__term">
<span class="pf-c-description-list__text">${msg("Grace period till")}</span>
</dt>
<dd class="pf-c-description-list__description">
<div class="pf-c-description-list__text">
<ak-timestamp
.timestamp=${this.iteration?.gracePeriodEnd}
.elapsed=${false}
dateonly
datetime
></ak-timestamp>
</div>
</dd>
</div>`;
}
protected renderNextReviewDate(): SlottedTemplateResult {
return html`<div class="pf-c-description-list__group">
<dt class="pf-c-description-list__term">
<span class="pf-c-description-list__text">${msg("Next review date")}</span>
</dt>
<dd class="pf-c-description-list__description">
<div class="pf-c-description-list__text">
<ak-timestamp
.timestamp=${this.iteration?.nextReviewDate}
.elapsed=${false}
dateonly
datetime
></ak-timestamp>
</div>
</dd>
</div>`;
}
protected renderReviewDates() {
return match(this.iteration?.state)
.with(P.nullish, LifecycleIterationStateEnum.UnknownDefaultOpenApi, () => nothing)
.with(
LifecycleIterationStateEnum.Pending,
() => html`${this.renderOpenedOn()}${this.renderGracePeriodTill()}`,
)
.with(LifecycleIterationStateEnum.Reviewed, () => this.renderNextReviewDate())
.with(LifecycleIterationStateEnum.Overdue, () => this.renderOpenedOn())
.with(LifecycleIterationStateEnum.Canceled, () => this.renderOpenedOn())
.exhaustive();
}
protected renderReviewSummary() {
return html`<div class="pf-c-card pf-l-grid__item pf-m-3-col">
<div class="pf-c-card__title">${msg("Latest review for this object")}</div>
<div class="pf-c-card__body">
<dl class="pf-c-description-list">
<div class="pf-c-description-list__group">
<dt class="pf-c-description-list__term">
<span class="pf-c-description-list__text">${msg("Review state")}</span>
</dt>
<dd class="pf-c-description-list__description">
<div class="pf-c-description-list__text">
${LifecycleIterationStatus({
status: this.iteration?.state,
})}
</div>
</dd>
</div>
<div class="pf-c-description-list__group">
<dt class="pf-c-description-list__term">
<span class="pf-c-description-list__text"
>${msg("Required reviewers")}</span
>
</dt>
<dd class="pf-c-description-list__description">
<div class="pf-c-description-list__text">${this.renderReviewers()}</div>
</dd>
</div>
${this.renderReviewDates()}
</dl>
</div>
</div>`;
}
//#endregion
//#region Table
protected row(item: Review): SlottedTemplateResult[] {
return [
Timestamp(item.timestamp),
html`<span>${item.reviewer.name}</span>`,
html`<span>${item.note}</span>`,
];
}
protected override renderEmpty(): TemplateResult {
return super.renderEmpty(
html`<ak-empty-state icon="pf-icon-task"
><span>${this.emptyStateMessage}</span></ak-empty-state
>`,
);
}
protected renderObjectCreate(): SlottedTemplateResult {
if (!this.iteration?.userCanReview) {
return nothing;
}
return html`<ak-forms-modal>
<span slot="submit">${msg("Confirm Review")}</span>
<span slot="header">${msg("Confirm this object has been reviewed")}</span>
<ak-object-review-form slot="form" .iteration=${this.iteration}>
</ak-object-review-form>
<button slot="trigger" class="pf-c-button pf-m-primary">
${msg("Confirm Review")}
</button>
</ak-forms-modal>`;
}
protected override render(): SlottedTemplateResult {
return html`<div class="pf-l-grid pf-m-gutter">
<div class="pf-c-card pf-l-grid__item pf-m-12-col">
<ak-lifecycle-preview-banner></ak-lifecycle-preview-banner>
</div>
${this.renderReviewSummary()}
<div class="pf-c-card pf-l-grid__item pf-m-9-col">
<div class="pf-c-card__title">${msg("Reviews")}</div>
<div class="pf-c-card__body">
${super.render()}
</div>
</div>
</div>
</div>`;
}
//#endregion
//#endregion
}
declare global {
interface HTMLElementTagNameMap {
"ak-object-lifecycle-page": ObjectLifecyclePage;
}
}
@@ -0,0 +1,46 @@
import "#components/ak-textarea-input";
import "#elements/forms/ModalForm";
import { DEFAULT_CONFIG } from "#common/api/config";
import { ModelForm } from "#elements/forms/ModelForm";
import { LifecycleApi, LifecycleIteration, Review } from "@goauthentik/api";
import { msg } from "@lit/localize";
import { html, TemplateResult } from "lit";
import { customElement, property } from "lit/decorators.js";
@customElement("ak-object-review-form")
export class ObjectReviewForm extends ModelForm<Review, string> {
@property({ attribute: false })
public iteration: LifecycleIteration | null = null;
protected loadInstance(_pk: string): Promise<Review> {
throw new TypeError("Reviews should not be edited.");
}
protected send(data: Review): Promise<unknown> {
return new LifecycleApi(DEFAULT_CONFIG).lifecycleReviewsCreate({
reviewRequest: data,
});
}
protected override serialize(): Review | null {
const review = super.serialize();
if (!review || !this.iteration) return null;
review.iteration = this.iteration.id;
return review;
}
renderForm(): TemplateResult {
return html`<ak-textarea-input
label=${msg("Review Notes")}
placeholder=${msg("Type optional notes to include in this review...")}
name="note"
></ak-textarea-input>`;
}
}
+91
View File
@@ -0,0 +1,91 @@
import "#admin/policies/BoundPoliciesList";
import "#admin/rbac/ObjectPermissionModal";
import "#components/ak-status-label";
import "#elements/buttons/SpinnerButton/index";
import "#elements/forms/DeleteBulkForm";
import "#elements/forms/ModalForm";
import "#elements/tasks/TaskList";
import "@patternfly/elements/pf-tooltip/pf-tooltip.js";
import "#admin/lifecycle/LifecyclePreviewBanner";
import "#components/ak-switch-input";
import { DEFAULT_CONFIG } from "#common/api/config";
import { PaginatedResponse, TableColumn } from "#elements/table/Table";
import { TablePage } from "#elements/table/TablePage";
import { SlottedTemplateResult } from "#elements/types";
import { LifecycleIterationStatus } from "#admin/lifecycle/utils";
import { LifecycleApi, LifecycleIteration } from "@goauthentik/api";
import { msg } from "@lit/localize";
import { html, TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators.js";
@customElement("ak-review-list")
export class ReviewListPage extends TablePage<LifecycleIteration> {
expandable = false;
checkbox = false;
clearOnRefresh = true;
protected override searchEnabled = false;
public pageTitle = msg("Open Reviews");
public pageDescription = msg("See all currently open reviews.");
public pageIcon = "pf-icon pf-icon-history";
@property()
order = "grace_period_till";
@state()
showOnlyMine = false;
async apiEndpoint(): Promise<PaginatedResponse<LifecycleIteration>> {
return new LifecycleApi(DEFAULT_CONFIG).lifecycleIterationsListOpen({
...(await this.defaultEndpointConfig()),
userIsReviewer: this.showOnlyMine || undefined,
});
}
protected renderSectionBefore?(): TemplateResult {
return html`<ak-lifecycle-preview-banner></ak-lifecycle-preview-banner>`;
}
protected renderToolbar(): TemplateResult {
return html`
<ak-switch-input
name="showOnlyMine"
?checked=${this.showOnlyMine}
label=${msg("Only show reviews where I am a reviewer")}
@change=${() => {
this.showOnlyMine = !this.showOnlyMine;
this.fetch();
}}
>
</ak-switch-input>
${super.renderToolbar()}
`;
}
protected columns: TableColumn[] = [
[msg("State"), "state"],
[msg("Object"), "content_type__model"],
[msg("Opened"), "opened_on"],
[msg("Grace period ends")],
];
row(item: LifecycleIteration): SlottedTemplateResult[] {
return [
LifecycleIterationStatus({ status: item.state }),
html`<a href="#${item.objectAdminUrl}">${item.objectVerbose}</a>`,
html`<ak-timestamp .timestamp=${item.openedOn} datetime dateonly></ak-timestamp>`,
html`<ak-timestamp .timestamp=${item.gracePeriodEnd} datetime dateonly></ak-timestamp>`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"ak-review-list": ReviewListPage;
}
}
+34
View File
@@ -0,0 +1,34 @@
import { PFColor } from "#elements/Label";
import { LitFC } from "#elements/types";
import { LifecycleIterationStateEnum } from "@goauthentik/api";
import { match } from "ts-pattern";
import { msg } from "@lit/localize";
import { html } from "lit";
export interface LifecycleIterationStatusProps {
status?: LifecycleIterationStateEnum;
}
export const LifecycleIterationStatus: LitFC<LifecycleIterationStatusProps> = ({ status }) => {
return match(status)
.with(
LifecycleIterationStateEnum.Pending,
() => html`<ak-label color=${PFColor.Orange}>${msg("Pending review")}</ak-label>`,
)
.with(
LifecycleIterationStateEnum.Reviewed,
() => html`<ak-label color=${PFColor.Green}>${msg("Reviewed")}</ak-label>`,
)
.with(
LifecycleIterationStateEnum.Overdue,
() => html`<ak-label color=${PFColor.Red}>${msg("Overdue")}</ak-label>`,
)
.with(
LifecycleIterationStateEnum.Canceled,
() => html`<ak-label color=${PFColor.Grey}>${msg("Canceled")}</ak-label>`,
)
.otherwise(() => html`<ak-label color=${PFColor.Grey}>${msg("Unknown")}</ak-label>`);
};
+3 -1
View File
@@ -31,8 +31,10 @@ export class ObjectPermissionPage extends AKElement {
@property()
public model?: RbacPermissionsAssignedByRolesListModelEnum;
// TODO: Use attribute casing.
// @property({ attribute: "object-pk" })
@property()
public objectPk?: string | number;
public objectPk?: string | null;
@property({ type: Boolean })
public embedded = false;
@@ -7,6 +7,7 @@ import { DEFAULT_CONFIG } from "#common/api/config";
import { PaginatedResponse, Table, TableColumn } from "#elements/table/Table";
import { SlottedTemplateResult } from "#elements/types";
import { ifPresent } from "#elements/utils/attributes";
import type { Pagination } from "@goauthentik/api";
import {
@@ -19,7 +20,6 @@ import {
import { msg } from "@lit/localize";
import { html, TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { ifDefined } from "lit/directives/if-defined.js";
const FALLBACK_PAGINATED_RESPONSE: { pagination: Pagination; results: [] } = {
pagination: {
@@ -36,11 +36,13 @@ const FALLBACK_PAGINATED_RESPONSE: { pagination: Pagination; results: [] } = {
@customElement("ak-rbac-role-object-permission-table")
export class RoleAssignedObjectPermissionTable extends Table<RoleAssignedObjectPermission> {
@property()
model?: RbacPermissionsAssignedByRolesListModelEnum;
@property({ type: String })
public model: RbacPermissionsAssignedByRolesListModelEnum | null = null;
// TODO: Use attribute casing.
// @property({ attribute: "object-pk" })
@property()
objectPk?: string | number;
public objectPk?: string | number;
@state()
modelPermissions?: PaginatedPermissionList;
@@ -89,8 +91,8 @@ export class RoleAssignedObjectPermissionTable extends Table<RoleAssignedObjectP
<span slot="submit">${msg("Assign")}</span>
<span slot="header">${msg("Assign object permissions to role")}</span>
<ak-rbac-role-object-permission-form
model=${ifDefined(this.model)}
objectPk=${ifDefined(this.objectPk)}
model=${ifPresent(this.model)}
objectPk=${ifPresent(this.objectPk)}
slot="form"
>
</ak-rbac-role-object-permission-form>
@@ -115,7 +117,7 @@ export class RoleAssignedObjectPermissionTable extends Table<RoleAssignedObjectP
uuid: item.rolePk,
patchedPermissionAssignRequest: {
objectPk: this.objectPk?.toString(),
model: this.model,
model: this.model || undefined,
permissions: item.objectPermissions.map((perm) => {
return `${perm.appLabel}.${perm.codename}`;
}),
+21 -2
View File
@@ -1,6 +1,7 @@
import "#admin/groups/RelatedGroupList";
import "#admin/groups/RelatedUserList";
import "#admin/rbac/ObjectPermissionsPage";
import "#admin/lifecycle/ObjectLifecyclePage";
import "#admin/roles/RoleForm";
import "#components/events/ObjectChangelog";
import "#components/events/UserEvents";
@@ -11,11 +12,17 @@ import { DEFAULT_CONFIG } from "#common/api/config";
import { EVENT_REFRESH } from "#common/constants";
import { AKElement } from "#elements/Base";
import { WithLicenseSummary } from "#elements/mixins/license";
import { setPageDetails } from "#components/ak-page-navbar";
import { renderDescriptionList } from "#components/DescriptionList";
import { RbacApi, RbacPermissionsAssignedByRolesListModelEnum, Role } from "@goauthentik/api";
import {
ContentTypeEnum,
RbacApi,
RbacPermissionsAssignedByRolesListModelEnum,
Role,
} from "@goauthentik/api";
import { msg, str } from "@lit/localize";
import { css, html, nothing, PropertyValues } from "lit";
@@ -30,7 +37,7 @@ import PFGrid from "@patternfly/patternfly/layouts/Grid/grid.css";
import PFDisplay from "@patternfly/patternfly/utilities/Display/display.css";
@customElement("ak-role-view")
export class RoleViewPage extends AKElement {
export class RoleViewPage extends WithLicenseSummary(AKElement) {
@property({ type: String })
set roleId(id: string) {
new RbacApi(DEFAULT_CONFIG)
@@ -150,6 +157,18 @@ export class RoleViewPage extends AKElement {
model=${RbacPermissionsAssignedByRolesListModelEnum.AuthentikRbacRole}
objectPk=${this.targetRole.pk}
></ak-rbac-object-permission-page>
${this.hasEnterpriseLicense
? html`<ak-object-lifecycle-page
class="pf-c-page__main-section pf-m-no-padding-mobile"
role="tabpanel"
tabindex="0"
slot="page-lifecycle"
id="page-lifecycle"
aria-label="${msg("Lifecycle")}"
model=${ContentTypeEnum.AuthentikRbacRole}
object-pk=${this.targetRole.pk}
></ak-object-lifecycle-page>`
: nothing}
</ak-tabs>
</main>`;
}
+5 -6
View File
@@ -77,16 +77,15 @@ export class PromptForm extends ModelForm<Prompt, string> {
}
async refreshPreview(prompt?: Prompt): Promise<void> {
if (!prompt) {
prompt = this.serialize();
if (!prompt) {
return;
}
const promptRequest = prompt || this.serialize();
if (!promptRequest) {
return;
}
return new StagesApi(DEFAULT_CONFIG)
.stagesPromptPromptsPreviewCreate({
promptRequest: prompt,
promptRequest,
})
.then((nextPreview) => {
this.preview = nextPreview;
+43 -1
View File
@@ -2,7 +2,9 @@
* @file Utilities for API requests
*/
import { APIError } from "#common/errors/network";
import type { APIError } from "#common/errors/network";
import type { Pagination } from "@goauthentik/api";
export interface APIResultLoading {
loading: true;
@@ -26,3 +28,43 @@ export function isAPIResultReady<T extends object>(
): result is APIResultSucccess<T> {
return !!(result && result.loading !== false && result.error !== null);
}
/**
* A generic interface for paginated API responses.
*
* @template T The type of the items in the results array.
* @template A The type of the autocomplete object, if present.
*/
export interface PaginatedResponse<T, A extends object = object> {
pagination: Pagination;
autocomplete?: A;
results: Array<T>;
}
/**
* Create a {@link PaginatedResponse} from an iterable of items.
*
* @template T The type of the items in the results array.
* @template A The type of the autocomplete object, if present.
* @param input An iterable of items to include in the results array.
*/
export function createPaginatedResponse<T = unknown, A extends object = object>(
input: Iterable<T> = [],
): PaginatedResponse<T, A> {
const results = Array.from(input);
return {
pagination: {
count: results.length,
next: 0,
previous: 0,
current: 0,
totalPages: 1,
startIndex: 0,
endIndex: 0,
},
results,
autocomplete: {} as A,
};
}
+2 -2
View File
@@ -2,7 +2,7 @@ import "#elements/forms/Radio";
import { HorizontalLightComponent } from "./HorizontalLightComponent.js";
import { RadioOption } from "#elements/forms/Radio";
import { RadioChangeEventDetail, RadioOption } from "#elements/forms/Radio";
import { SlottedTemplateResult } from "#elements/types";
import { html, nothing } from "lit";
@@ -18,7 +18,7 @@ export class AkRadioInput<T> extends HorizontalLightComponent<T> {
@property({ attribute: false })
public options: RadioOption<T>[] | (() => RadioOption<T>[]) = [];
handleInput(ev: CustomEvent) {
handleInput(ev: CustomEvent<RadioChangeEventDetail<T>>): void {
if ("detail" in ev) {
this.value = ev.detail.value;
}
+8 -2
View File
@@ -1,5 +1,7 @@
import { HorizontalLightComponent } from "./HorizontalLightComponent.js";
import { ifPresent } from "#elements/utils/attributes";
import { html } from "lit";
import { customElement, property } from "lit/decorators.js";
import { ifDefined } from "lit/directives/if-defined.js";
@@ -9,6 +11,9 @@ export class AkTextareaInput extends HorizontalLightComponent<string> {
@property({ type: String, reflect: true })
public value = "";
@property({ type: String })
public placeholder: string | null = null;
public override renderControl() {
const code = this.inputHint === "code";
const setValue = (ev: InputEvent) => {
@@ -22,8 +27,9 @@ export class AkTextareaInput extends HorizontalLightComponent<string> {
class="pf-c-form-control"
?required=${this.required}
name=${this.name}
autocomplete=${ifDefined(code ? "off" : undefined)}
spellcheck=${ifDefined(code ? "false" : undefined)}
placeholder=${ifPresent(this.placeholder)}
autocomplete=${ifPresent(code, "off")}
spellcheck=${ifPresent(code, "false")}
>${this.value !== undefined ? this.value : ""}</textarea
> `;
}
+16 -2
View File
@@ -356,7 +356,7 @@ export abstract class Form<T = Record<string, unknown>> extends AKElement {
/**
* Convert the elements of the form to JSON.[4]
*/
protected serialize(): T | undefined {
protected serialize(): T | null {
const elements = this.shadowRoot?.querySelectorAll("ak-form-element-horizontal");
if (!elements) {
@@ -374,7 +374,21 @@ export abstract class Form<T = Record<string, unknown>> extends AKElement {
public submit = (event: SubmitEvent): Promise<unknown | false> => {
event.preventDefault();
const data = this.serialize();
let data: T | null;
try {
data = this.serialize();
} catch (error) {
console.error("authentik/forms: An error occurred while serializing the form.", error);
showMessage({
level: MessageLevel.error,
message: msg("An unknown error occurred while submitting the form."),
description: pluckErrorDetail(error),
});
return Promise.resolve(false);
}
if (!data) return Promise.resolve(false);
+6 -2
View File
@@ -21,6 +21,10 @@ export interface RadioOption<T> {
disabled?: boolean;
}
export interface RadioChangeEventDetail<T> {
value: T;
}
@customElement("ak-radio")
export class Radio<T = never> extends CustomEmitterElement(AKElement) {
static styles: CSSResult[] = [
@@ -77,8 +81,8 @@ export class Radio<T = never> extends CustomEmitterElement(AKElement) {
this.value = option.value;
this.dispatchCustomEvent("change", { value: option.value });
this.dispatchCustomEvent("input", { value: option.value });
this.dispatchCustomEvent<RadioChangeEventDetail<T>>("change", { value: option.value });
this.dispatchCustomEvent<RadioChangeEventDetail<T>>("input", { value: option.value });
};
};
+3 -9
View File
@@ -10,6 +10,7 @@ import "#elements/timestamp/ak-timestamp";
import { BaseTableListRequest, TableLike } from "./shared.js";
import { renderTableColumn, TableColumn } from "./TableColumn.js";
import { type PaginatedResponse } from "#common/api/responses";
import { EVENT_REFRESH } from "#common/constants";
import { APIError, parseAPIResponseError, pluckErrorDetail } from "#common/errors/network";
import { GroupResult } from "#common/utils";
@@ -27,8 +28,6 @@ import { isEventTargetingListener } from "#elements/utils/pointer";
import { ConsoleLogger, Logger } from "#logger/browser";
import { Pagination } from "@goauthentik/api";
import { kebabCase } from "change-case";
import { msg, str } from "@lit/localize";
@@ -50,12 +49,7 @@ import PFBullseye from "@patternfly/patternfly/layouts/Bullseye/bullseye.css";
export * from "./shared.js";
export * from "./TableColumn.js";
export interface PaginatedResponse<T> {
pagination: Pagination;
autocomplete?: { [key: string]: string };
results: Array<T>;
}
export type { PaginatedResponse };
export function hasPrimaryKey<T extends string | number = string | number>(
item: object,
@@ -759,7 +753,7 @@ export abstract class Table<T extends object>
//#region Toolbar
protected renderToolbar(): TemplateResult {
protected renderToolbar(): SlottedTemplateResult {
return html`${this.renderObjectCreate()}
<ak-spinner-button .callAction=${this.#refreshListener} class="pf-m-secondary">
${msg("Refresh")}</ak-spinner-button
+6 -1
View File
@@ -53,6 +53,9 @@ export class AKTimestamp extends AKElement {
@property({ type: Boolean, useDefault: true })
public datetime: boolean = false;
@property({ type: Boolean, useDefault: true })
public dateOnly: boolean = false;
@property({ type: Boolean, useDefault: true })
public refresh: boolean = false;
@@ -150,7 +153,9 @@ export class AKTimestamp extends AKElement {
${this.elapsed ? html`<div part="elapsed" id="elapsed">${elapsed}</div>` : nothing}
${this.datetime
? html`<small part="datetime" id="datetime"
>${this.timestamp.toLocaleString()}</small
>${this.dateOnly
? this.timestamp.toLocaleDateString()
: this.timestamp.toLocaleString()}</small
>`
: nothing}
</time>`;
+1
View File
@@ -676,6 +676,7 @@ const items = [
"sys-mgmt/settings",
"sys-mgmt/service-accounts",
"sys-mgmt/data-exports",
"sys-mgmt/object-lifecycle-management",
],
},
{
@@ -0,0 +1,118 @@
---
title: Object Lifecycle Management
description: "Configure authentik to auto-schedule and track periodic reviews of authentication settings for groups, roles, and applications."
sidebar_label: Object Lifecycle Management
authentik_enterprise: true
authentik_version: "2026.2.0"
authentik_preview: true
---
Object Lifecycle Management allows you to automate periodic reviews of authentication settings for groups, roles, and applications.
You can schedule reviews, track progress, and notify reviewers automatically.
## Lifecycle rules
Lifecycle rules define how often reviews are scheduled, the time before a review becomes overdue, who needs to approve a review, and how reviewers are notified.
You can create and configure Lifecycle rules via the **Events** > **Lifecycle Rules** page.
### Rule scope
A lifecycle rule can be scoped to:
- **A specific object**: The rule applies only to that individual Application, Group, or Role.
- **An entire object type**: The rule applies to all objects of that type that don't have their own specific rule, e.g., all applications.
When both a type-level rule and an object-specific rule exist, the object-specific rule takes precedence for that object.
### Rule settings
A lifecycle rule has the following settings:
| Setting | Description |
| ------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------- |
| **Object type** | The type of object this rule applies to. |
| **Object** | _(Optional)_ A specific object to apply this rule to. If left empty, the rule applies to all objects of the selected type. |
| **Interval** | How often reviews are scheduled (e.g., every 60 days). After a review is completed, the next review will be scheduled after this interval. |
| **Grace period** | The time period reviewers have to complete the review before it becomes overdue. Must be shorter than the interval. |
| **Reviewer groups** | Groups whose members can submit reviews. |
| **Min reviewers** | The minimum number of reviews required from members of any reviewing group. |
| **Min reviewers is per group** | When enabled, the minimum number of reviewers is required from each reviewer group separately. When disabled, it's a total across all groups. |
| **Explicit reviewers** | Individual users who must all submit a review, in addition to the reviewer groups requirement. |
| **Notification transports** | How reviewers are notified about pending, overdue, and completed reviews. |
### Reviewer requirements
An object's review is considered complete when all of the following conditions are met:
1. All explicit reviewers have submitted their reviews.
2. The minimum number of reviews from reviewer group members has been reached (either per group or in total, depending on the setting).
For example, if a rule has:
- Two explicit reviewers (Alice and Bob)
- Two reviewer groups (Security Team and Compliance Team)
- **Min reviewers** is set to 2
- **Min reviewers is per-group** is enabled
Then the review requires approval from: Alice, Bob, at least 2 members of the Security Team, and at least 2 members of the Compliance Team.
## Review states of an object
Each object governed by a lifecycle rule has a review state. You can view all objects with pending or overdue review states on the **Events** > **Reviews** page. You can also view an individual object's current review state on the **Lifecycle** tab of the object's detail page.
| State | Description |
| ------------ | -------------------------------------------------------------------------------------- |
| **Pending** | A review has been initiated and is waiting for reviewers. |
| **Overdue** | The grace period has passed without the review being completed. |
| **Reviewed** | All required reviews have been received and the review is complete. |
| **Canceled** | The review was canceled, typically because the lifecycle rule was deleted or modified. |
### Object review workflow
The following steps illustrate the workflow for an object lifecycle review process:
1. When a lifecycle rule is created or when the interval since the last completed review has elapsed, the object enters the **Pending** review state and reviewers are notified.
2. Reviewers submit their reviews (with an optional note).
3. After all requirements are met, the object transitions to the **Reviewed** state.
4. If the grace period passes without all requirements being met, the object becomes **Overdue** and reviewers receive an alert.
5. After the interval passes, a new review cycle begins and the object returns to the **Pending** state.
## Reviewer workflow
To review and approve an object and its associated lifecycle rule, follow the steps below. A reviewer can be either a user set as an explicit reviewer or a member of a configured reviewer group.
1. Once a new review cycle starts for an object, you receive a notification that a review is due (via the configured notification transports).
2. Click on the link in the notification to navigate to the object's detail page.
Alternatively, you can navigate to the **Events** > **Reviews** page and enable "Only show reviews where I am a reviewer" filter to see objects awaiting your review.
Here, you can click on the object to navigate to its detail page.
In both cases, you will be taken to the **Lifecycle** tab of the object's detail page.
3. Review the object's current configuration.
4. Go back to the **Lifecycle** tab.
5. Click **Review** to submit your review, optionally including a note.
6. Once all reviewer requirements are met, the object automatically transitions to the **Reviewed** state.
### Submit a review
When an object is in the **Pending** or **Overdue** review state, authorized reviewers can submit reviews for it. Each reviewer can only submit one review per review cycle. When submitting a review, reviewers can optionally include a note explaining their decision.
Only authorized reviewers can submit reviews:
- Members of the configured reviewer groups
- Users listed as explicit reviewers
### Notifications
Reviewers are notified at the following events:
| Event | Severity | Description |
| ---------------- | -------- | --------------------------------------------------------------- |
| Review initiated | Notice | An object has entered the Pending review state. |
| Review overdue | Alert | The grace period has passed and the review is still incomplete. |
| Review completed | Notice | All required reviews have been received. |
Configure notification transports on the lifecycle rule to control how these notifications are delivered (UI notification, email, webhook, etc.).