mirror of
https://github.com/goauthentik/authentik.git
synced 2026-06-17 19:09:11 +03:00
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:
committed by
GitHub
parent
233377e86c
commit
2f2488b326
@@ -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)
|
||||
@@ -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"]
|
||||
@@ -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")},
|
||||
),
|
||||
]
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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),
|
||||
]
|
||||
@@ -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"]
|
||||
@@ -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"),
|
||||
]
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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
@@ -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, [
|
||||
|
||||
@@ -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>`;
|
||||
}
|
||||
|
||||
@@ -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>`;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>`);
|
||||
};
|
||||
@@ -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}`;
|
||||
}),
|
||||
|
||||
@@ -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>`;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
> `;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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 });
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>`;
|
||||
|
||||
@@ -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.).
|
||||
Reference in New Issue
Block a user