From 8963d29ab480d1c8a50961badc5d1e12eee9128c Mon Sep 17 00:00:00 2001 From: Alexander Tereshkin <96586+atereshkin@users.noreply.github.com> Date: Thu, 30 Apr 2026 22:11:07 +0300 Subject: [PATCH] enterprise/lifecycle: remove one review per object limitation (#21046) * enterprise/lifecycle: allow multiple rules to apply to a single object (and thus, multiple concurrent reviews) * enterprise/lifecyle: add missing migration to allow multiple lifecycle rules per object, add tests, update documentation * enterprise/lifecycle: add a bit of padding to individual review iterations on Review tab for better visual separation * enterprise/lifecycle: remove validation preventing the creation of multiple lifecycle rules for one object type * enterprise/lifecycle: change the approach to querying the list of reviews with user_is_reviewer annotation to prevent duplicate rows * enterprise/lifecycle: add custom per-type logic to get object name for use in a notification to prevent texts like "Review is due for Group Group X" * enterprise/lifecycle: updated wording on lifecycle rule form and preview banner padding * enterprise/lifecycle: remove task list from lifecycle rules and switch to using per-rule schedules * enterprise/lifecycle: add a title to the lifecycle tab * Revert "enterprise/lifecycle: remove task list from lifecycle rules and switch to using per-rule schedules" This reverts commit 8a060015b693f65f651a71bdb0c47092d3463af1. * enterprise/lifecycle: remove task list from the lifecycle rule list page and attach the tasks to the schedule * enterprise/lifecycle: add proper caption when there are no reviews for an object * enterprise/lifecycle: attach individual apply_lifecycle_rule tasks to the schedule when launched from apply_lifecycle_rules * enterprise/lifecycle: update generated API clients * enterprise/lifecycle: update wording * enterprise/lifecycle: fix ts issues after rebase * Update website/docs/sys-mgmt/object-lifecycle-management.md Co-authored-by: Dominic R Signed-off-by: Alexander Tereshkin <96586+atereshkin@users.noreply.github.com> * enterprise/lifecycle: remove fmall code artifact --------- Signed-off-by: Alexander Tereshkin <96586+atereshkin@users.noreply.github.com> Co-authored-by: Dominic R --- .../enterprise/lifecycle/api/iterations.py | 84 +++--- authentik/enterprise/lifecycle/api/rules.py | 17 -- ..._lifecycle_rule_ct_null_object_and_more.py | 21 ++ authentik/enterprise/lifecycle/models.py | 25 +- authentik/enterprise/lifecycle/signals.py | 5 +- authentik/enterprise/lifecycle/tasks.py | 7 +- .../enterprise/lifecycle/tests/test_api.py | 25 +- .../enterprise/lifecycle/tests/test_models.py | 103 +++++-- packages/client-ts/src/apis/LifecycleApi.ts | 45 ++- .../src/models/LifecycleIteration.ts | 42 +-- packages/client-ts/src/models/RelatedRule.ts | 103 +++++++ packages/client-ts/src/models/index.ts | 1 + schema.yml | 62 ++-- web/src/admin/lifecycle/LifecycleRuleForm.ts | 4 +- .../admin/lifecycle/LifecycleRuleListPage.ts | 21 -- .../admin/lifecycle/ObjectLifecyclePage.ts | 280 +++-------------- .../admin/lifecycle/ObjectReviewIteration.ts | 281 ++++++++++++++++++ web/src/admin/lifecycle/ReviewListPage.ts | 2 + .../sys-mgmt/object-lifecycle-management.md | 34 ++- 19 files changed, 726 insertions(+), 436 deletions(-) create mode 100644 authentik/enterprise/lifecycle/migrations/0003_remove_lifecyclerule_uniq_lifecycle_rule_ct_null_object_and_more.py create mode 100644 packages/client-ts/src/models/RelatedRule.ts create mode 100644 web/src/admin/lifecycle/ObjectReviewIteration.ts diff --git a/authentik/enterprise/lifecycle/api/iterations.py b/authentik/enterprise/lifecycle/api/iterations.py index e195103e6b..08a115a54e 100644 --- a/authentik/enterprise/lifecycle/api/iterations.py +++ b/authentik/enterprise/lifecycle/api/iterations.py @@ -1,7 +1,6 @@ from datetime import datetime -from django.db.models import BooleanField as ModelBooleanField -from django.db.models import Case, Q, Value, When +from django.db.models import Exists, OuterRef, Q, Subquery from django_filters.rest_framework import BooleanFilter, FilterSet from drf_spectacular.utils import extend_schema from rest_framework.decorators import action @@ -14,7 +13,7 @@ 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.models import LifecycleIteration, LifecycleRule, ReviewState from authentik.enterprise.lifecycle.utils import ( ContentTypeField, ReviewerGroupSerializer, @@ -26,20 +25,25 @@ from authentik.enterprise.lifecycle.utils import ( from authentik.lib.utils.time import timedelta_from_string +class RelatedRuleSerializer(EnterpriseRequiredMixin, ModelSerializer): + reviewer_groups = ReviewerGroupSerializer(many=True, read_only=True) + min_reviewers = IntegerField(read_only=True) + reviewers = ReviewerUserSerializer(many=True, read_only=True) + + class Meta: + model = LifecycleRule + fields = ["id", "name", "reviewer_groups", "min_reviewers", "reviewers"] + + class LifecycleIterationSerializer(EnterpriseRequiredMixin, ModelSerializer): content_type = ContentTypeField() object_verbose = SerializerMethodField() + rule = RelatedRuleSerializer(read_only=True) 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: @@ -55,10 +59,8 @@ class LifecycleIterationSerializer(EnterpriseRequiredMixin, ModelSerializer): "grace_period_end", "next_review_date", "reviews", + "rule", "user_can_review", - "reviewer_groups", - "min_reviewers", - "reviewers", ] read_only_fields = fields @@ -88,43 +90,55 @@ 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"] + ordering_fields = [ + "state", + "content_type__model", + "rule__name", + "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(), + user_is_reviewer=Exists( + LifecycleRule.objects.filter( + pk=OuterRef("rule_id"), + ).filter( + Q(reviewers=user) | Q(reviewer_groups__in=user.groups.all().with_ancestors()) + ) ) - ).distinct() + ) + @extend_schema( + operation_id="lifecycle_iterations_list_latest", + responses={200: LifecycleIterationSerializer(many=True)}, + ) @action( detail=False, + pagination_class=None, methods=["get"], url_path=r"latest/(?P[^/]+)/(?P[^/]+)", ) - def latest_iteration(self, request: Request, content_type: str, object_id: str) -> Response: + def latest_iterations(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") + latest_ids_subquery = ( + LifecycleIteration.objects.filter( + rule=OuterRef("rule"), + content_type__app_label=ct["app_label"], + content_type__model=ct["model"], + object_id=object_id, ) - except LifecycleIteration.DoesNotExist: - return Response(status=404) - serializer = self.get_serializer(obj) + .order_by("-opened_on") + .values("id")[:1] + ) + latest_per_rule = LifecycleIteration.objects.filter( + content_type__app_label=ct["app_label"], + content_type__model=ct["model"], + object_id=object_id, + ).filter(id=Subquery(latest_ids_subquery)) + serializer = self.get_serializer(latest_per_rule, many=True) return Response(serializer.data) @extend_schema( diff --git a/authentik/enterprise/lifecycle/api/rules.py b/authentik/enterprise/lifecycle/api/rules.py index 905f41ac38..982a40ad3f 100644 --- a/authentik/enterprise/lifecycle/api/rules.py +++ b/authentik/enterprise/lifecycle/api/rules.py @@ -84,23 +84,6 @@ class LifecycleRuleSerializer(EnterpriseRequiredMixin, ModelSerializer): 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 diff --git a/authentik/enterprise/lifecycle/migrations/0003_remove_lifecyclerule_uniq_lifecycle_rule_ct_null_object_and_more.py b/authentik/enterprise/lifecycle/migrations/0003_remove_lifecyclerule_uniq_lifecycle_rule_ct_null_object_and_more.py new file mode 100644 index 0000000000..b2a23d0de3 --- /dev/null +++ b/authentik/enterprise/lifecycle/migrations/0003_remove_lifecyclerule_uniq_lifecycle_rule_ct_null_object_and_more.py @@ -0,0 +1,21 @@ +# Generated by Django 5.2.11 on 2026-03-05 11:27 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_lifecycle", "0002_alter_lifecycleiteration_opened_on"), + ] + + operations = [ + migrations.RemoveConstraint( + model_name="lifecyclerule", + name="uniq_lifecycle_rule_ct_null_object", + ), + migrations.AlterUniqueTogether( + name="lifecyclerule", + unique_together=set(), + ), + ] diff --git a/authentik/enterprise/lifecycle/models.py b/authentik/enterprise/lifecycle/models.py index 6f0daf0781..9a4e195588 100644 --- a/authentik/enterprise/lifecycle/models.py +++ b/authentik/enterprise/lifecycle/models.py @@ -56,14 +56,6 @@ class LifecycleRule(SerializerModel): 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]: @@ -82,12 +74,6 @@ class LifecycleRule(SerializerModel): 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]: @@ -107,8 +93,7 @@ class LifecycleRule(SerializerModel): def _get_newly_due_objects(self) -> QuerySet: recent_iteration_ids = LifecycleIteration.objects.filter( - content_type=self.content_type, - object_id__isnull=False, + rule=self, opened_on__gte=start_of_day( timezone.now() + timedelta(days=1) - timedelta_from_string(self.interval) ), @@ -214,9 +199,15 @@ class LifecycleIteration(SerializerModel, ManagedModel): } def initialize(self): + if (self.content_type.app_label, self.content_type.model) == ("authentik_core", "group"): + object_label = self.object.name + elif (self.content_type.app_label, self.content_type.model) == ("authentik_rbac", "role"): + object_label = self.object.name + else: + object_label = str(self.object) event = Event.new( EventAction.REVIEW_INITIATED, - message=_(f"Access review is due for {self.content_type.name} {str(self.object)}"), + message=_(f"Access review is due for {self.content_type.name.lower()} {object_label}"), **self._get_event_args(), ) event.save() diff --git a/authentik/enterprise/lifecycle/signals.py b/authentik/enterprise/lifecycle/signals.py index c51104ec86..6ece1a45f5 100644 --- a/authentik/enterprise/lifecycle/signals.py +++ b/authentik/enterprise/lifecycle/signals.py @@ -3,6 +3,7 @@ from django.db.models.signals import post_save, pre_delete from django.dispatch import receiver from authentik.enterprise.lifecycle.models import LifecycleRule, ReviewState +from authentik.tasks.schedules.models import Schedule @receiver(post_save, sender=LifecycleRule) @@ -11,7 +12,9 @@ def post_rule_save(sender, instance: LifecycleRule, created: bool, **_): apply_lifecycle_rule.send_with_options( args=(instance.id,), - rel_obj=instance, + rel_obj=Schedule.objects.get( + actor_name="authentik.enterprise.lifecycle.tasks.apply_lifecycle_rules" + ), ) diff --git a/authentik/enterprise/lifecycle/tasks.py b/authentik/enterprise/lifecycle/tasks.py index fb2c79cd61..be45694a19 100644 --- a/authentik/enterprise/lifecycle/tasks.py +++ b/authentik/enterprise/lifecycle/tasks.py @@ -4,14 +4,17 @@ 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 +from authentik.tasks.schedules.models import Schedule -@actor(description=_("Dispatch tasks to validate lifecycle rules.")) +@actor(description=_("Dispatch tasks to apply lifecycle rules.")) def apply_lifecycle_rules(): for rule in LifecycleRule.objects.all(): apply_lifecycle_rule.send_with_options( args=(rule.id,), - rel_obj=rule, + rel_obj=Schedule.objects.get( + actor_name="authentik.enterprise.lifecycle.tasks.apply_lifecycle_rules" + ), ) diff --git a/authentik/enterprise/lifecycle/tests/test_api.py b/authentik/enterprise/lifecycle/tests/test_api.py index 67838e7b76..456f2d8818 100644 --- a/authentik/enterprise/lifecycle/tests/test_api.py +++ b/authentik/enterprise/lifecycle/tests/test_api.py @@ -1,3 +1,4 @@ +from django.apps import apps from django.contrib.contenttypes.models import ContentType from django.urls import reverse from rest_framework.test import APITestCase @@ -19,6 +20,11 @@ class TestLifecycleRuleAPI(APITestCase): self.content_type = ContentType.objects.get_for_model(Application) self.reviewer_group = Group.objects.create(name=generate_id()) + @classmethod + def setUpTestData(cls): + config = apps.get_app_config("authentik_tasks_schedules") + config._on_startup_callback(None) + def test_list_rules(self): rule = LifecycleRule.objects.create( name=generate_id(), @@ -190,6 +196,11 @@ class TestIterationAPI(APITestCase): self.reviewer_group = Group.objects.create(name=generate_id()) self.reviewer_group.users.add(self.user) + @classmethod + def setUpTestData(cls): + config = apps.get_app_config("authentik_tasks_schedules") + config._on_startup_callback(None) + def test_open_iterations(self): rule = LifecycleRule.objects.create( name=generate_id(), @@ -231,7 +242,7 @@ class TestIterationAPI(APITestCase): response = self.client.get( reverse( - "authentik_api:lifecycleiteration-latest-iteration", + "authentik_api:lifecycleiteration-latest-iterations", kwargs={ "content_type": f"{self.content_type.app_label}.{self.content_type.model}", "object_id": str(self.app.pk), @@ -239,19 +250,20 @@ class TestIterationAPI(APITestCase): ) ) self.assertEqual(response.status_code, 200) - self.assertEqual(response.data["object_id"], str(self.app.pk)) + self.assertEqual(len(response.data), 1) + self.assertEqual(response.data[0]["object_id"], str(self.app.pk)) def test_latest_iteration_not_found(self): response = self.client.get( reverse( - "authentik_api:lifecycleiteration-latest-iteration", + "authentik_api:lifecycleiteration-latest-iterations", 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) + self.assertEqual(response.data, []) def test_iteration_includes_user_can_review(self): rule = LifecycleRule.objects.create( @@ -279,6 +291,11 @@ class TestReviewAPI(APITestCase): self.reviewer_group = Group.objects.create(name=generate_id()) self.reviewer_group.users.add(self.user) + @classmethod + def setUpTestData(cls): + config = apps.get_app_config("authentik_tasks_schedules") + config._on_startup_callback(None) + def test_create_review(self): rule = LifecycleRule.objects.create( name=generate_id(), diff --git a/authentik/enterprise/lifecycle/tests/test_models.py b/authentik/enterprise/lifecycle/tests/test_models.py index 0936c93362..7a8ccdf2fe 100644 --- a/authentik/enterprise/lifecycle/tests/test_models.py +++ b/authentik/enterprise/lifecycle/tests/test_models.py @@ -2,6 +2,7 @@ import datetime as dt from datetime import timedelta from unittest.mock import patch +from django.apps import apps from django.contrib.contenttypes.models import ContentType from django.test import RequestFactory, TestCase from django.utils import timezone @@ -29,6 +30,11 @@ class TestLifecycleModels(TestCase): def setUp(self): self.factory = RequestFactory() + @classmethod + def setUpTestData(cls): + config = apps.get_app_config("authentik_tasks_schedules") + config._on_startup_callback(None) + def _get_request(self): return self.factory.get("/") @@ -438,31 +444,6 @@ class TestLifecycleModels(TestCase): 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()) @@ -669,6 +650,73 @@ class TestLifecycleModels(TestCase): self.assertIn(explicit_reviewer, reviewers) self.assertIn(group_member, reviewers) + def test_multiple_rules_same_object_create_separate_iterations(self): + """Two rules targeting the same object each create their own iteration.""" + obj = Application.objects.create(name=generate_id(), slug=generate_id()) + content_type = ContentType.objects.get_for_model(obj) + + rule_one = self._create_rule_for_object(obj, interval="days=30", grace_period="days=10") + rule_two = self._create_rule_for_object(obj, interval="days=60", grace_period="days=20") + + iterations = LifecycleIteration.objects.filter( + content_type=content_type, object_id=str(obj.pk) + ) + self.assertEqual(iterations.count(), 2) + + iter_one = iterations.get(rule=rule_one) + iter_two = iterations.get(rule=rule_two) + self.assertEqual(iter_one.state, ReviewState.PENDING) + self.assertEqual(iter_two.state, ReviewState.PENDING) + self.assertNotEqual(iter_one.pk, iter_two.pk) + + def test_multiple_rules_same_object_reviewed_independently(self): + """Reviewing one rule's iteration does not affect the other rule's iteration.""" + obj = Application.objects.create(name=generate_id(), slug=generate_id()) + content_type = ContentType.objects.get_for_model(obj) + + reviewer = create_test_user() + + rule_one = self._create_rule_for_object(obj, min_reviewers=1) + rule_two = self._create_rule_for_object(obj, min_reviewers=1) + + group = Group.objects.create(name=generate_id()) + group.users.add(reviewer) + rule_one.reviewer_groups.add(group) + rule_two.reviewer_groups.add(group) + + iter_one = LifecycleIteration.objects.get( + content_type=content_type, object_id=str(obj.pk), rule=rule_one + ) + iter_two = LifecycleIteration.objects.get( + content_type=content_type, object_id=str(obj.pk), rule=rule_two + ) + + request = self._get_request() + + # Review only rule_one's iteration + Review.objects.create(iteration=iter_one, reviewer=reviewer) + iter_one.on_review(request) + + iter_one.refresh_from_db() + iter_two.refresh_from_db() + self.assertEqual(iter_one.state, ReviewState.REVIEWED) + self.assertEqual(iter_two.state, ReviewState.PENDING) + + def test_type_rule_and_object_rule_both_create_iterations(self): + """A type-level rule and an object-level rule both create iterations for the same object.""" + obj = Application.objects.create(name=generate_id(), slug=generate_id()) + content_type = ContentType.objects.get_for_model(obj) + + object_rule = self._create_rule_for_object(obj, interval="days=30") + type_rule = self._create_rule_for_type(Application, interval="days=60") + + iterations = LifecycleIteration.objects.filter( + content_type=content_type, object_id=str(obj.pk) + ) + self.assertEqual(iterations.count(), 2) + self.assertTrue(iterations.filter(rule=object_rule).exists()) + self.assertTrue(iterations.filter(rule=type_rule).exists()) + class TestLifecycleDateBoundaries(TestCase): """Verify that start_of_day normalization ensures correct overdue/due @@ -679,6 +727,11 @@ class TestLifecycleDateBoundaries(TestCase): ensures that the boundary is always at midnight, so millisecond variations in task execution time do not affect results.""" + @classmethod + def setUpTestData(cls): + config = apps.get_app_config("authentik_tasks_schedules") + config._on_startup_callback(None) + def _create_rule_and_iteration(self, grace_period="days=1", interval="days=365"): app = Application.objects.create(name=generate_id(), slug=generate_id()) content_type = ContentType.objects.get_for_model(Application) diff --git a/packages/client-ts/src/apis/LifecycleApi.ts b/packages/client-ts/src/apis/LifecycleApi.ts index d5cf76fe04..4df1753e39 100644 --- a/packages/client-ts/src/apis/LifecycleApi.ts +++ b/packages/client-ts/src/apis/LifecycleApi.ts @@ -40,9 +40,12 @@ export interface LifecycleIterationsCreateRequest { lifecycleIterationRequest: LifecycleIterationRequest; } -export interface LifecycleIterationsLatestRetrieveRequest { +export interface LifecycleIterationsListLatestRequest { contentType: string; objectId: string; + ordering?: string; + search?: string; + userIsReviewer?: boolean; } export interface LifecycleIterationsListOpenRequest { @@ -157,27 +160,39 @@ export class LifecycleApi extends runtime.BaseAPI { } /** - * Creates request options for lifecycleIterationsLatestRetrieve without sending the request + * Creates request options for lifecycleIterationsListLatest without sending the request */ - async lifecycleIterationsLatestRetrieveRequestOpts( - requestParameters: LifecycleIterationsLatestRetrieveRequest, + async lifecycleIterationsListLatestRequestOpts( + requestParameters: LifecycleIterationsListLatestRequest, ): Promise { if (requestParameters["contentType"] == null) { throw new runtime.RequiredError( "contentType", - 'Required parameter "contentType" was null or undefined when calling lifecycleIterationsLatestRetrieve().', + 'Required parameter "contentType" was null or undefined when calling lifecycleIterationsListLatest().', ); } if (requestParameters["objectId"] == null) { throw new runtime.RequiredError( "objectId", - 'Required parameter "objectId" was null or undefined when calling lifecycleIterationsLatestRetrieve().', + 'Required parameter "objectId" was null or undefined when calling lifecycleIterationsListLatest().', ); } const queryParameters: any = {}; + if (requestParameters["ordering"] != null) { + queryParameters["ordering"] = requestParameters["ordering"]; + } + + if (requestParameters["search"] != null) { + queryParameters["search"] = requestParameters["search"]; + } + + if (requestParameters["userIsReviewer"] != null) { + queryParameters["user_is_reviewer"] = requestParameters["userIsReviewer"]; + } + const headerParameters: runtime.HTTPHeaders = {}; if (this.configuration && this.configuration.accessToken) { @@ -210,27 +225,27 @@ export class LifecycleApi extends runtime.BaseAPI { /** * Mixin to validate that a valid enterprise license exists before allowing to save the object */ - async lifecycleIterationsLatestRetrieveRaw( - requestParameters: LifecycleIterationsLatestRetrieveRequest, + async lifecycleIterationsListLatestRaw( + requestParameters: LifecycleIterationsListLatestRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction, - ): Promise> { + ): Promise>> { const requestOptions = - await this.lifecycleIterationsLatestRetrieveRequestOpts(requestParameters); + await this.lifecycleIterationsListLatestRequestOpts(requestParameters); const response = await this.request(requestOptions, initOverrides); return new runtime.JSONApiResponse(response, (jsonValue) => - LifecycleIterationFromJSON(jsonValue), + jsonValue.map(LifecycleIterationFromJSON), ); } /** * Mixin to validate that a valid enterprise license exists before allowing to save the object */ - async lifecycleIterationsLatestRetrieve( - requestParameters: LifecycleIterationsLatestRetrieveRequest, + async lifecycleIterationsListLatest( + requestParameters: LifecycleIterationsListLatestRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction, - ): Promise { - const response = await this.lifecycleIterationsLatestRetrieveRaw( + ): Promise> { + const response = await this.lifecycleIterationsListLatestRaw( requestParameters, initOverrides, ); diff --git a/packages/client-ts/src/models/LifecycleIteration.ts b/packages/client-ts/src/models/LifecycleIteration.ts index 83c6463a77..281ebd1771 100644 --- a/packages/client-ts/src/models/LifecycleIteration.ts +++ b/packages/client-ts/src/models/LifecycleIteration.ts @@ -16,12 +16,10 @@ import type { ContentTypeEnum } from "./ContentTypeEnum"; import { ContentTypeEnumFromJSON, ContentTypeEnumToJSON } from "./ContentTypeEnum"; import type { LifecycleIterationStateEnum } from "./LifecycleIterationStateEnum"; import { LifecycleIterationStateEnumFromJSON } from "./LifecycleIterationStateEnum"; +import type { RelatedRule } from "./RelatedRule"; +import { RelatedRuleFromJSON } from "./RelatedRule"; import type { Review } from "./Review"; import { ReviewFromJSON } from "./Review"; -import type { ReviewerGroup } from "./ReviewerGroup"; -import { ReviewerGroupFromJSON } from "./ReviewerGroup"; -import type { ReviewerUser } from "./ReviewerUser"; -import { ReviewerUserFromJSON } from "./ReviewerUser"; /** * Mixin to validate that a valid enterprise license @@ -90,30 +88,18 @@ export interface LifecycleIteration { * @memberof LifecycleIteration */ readonly reviews: Array; + /** + * + * @type {RelatedRule} + * @memberof LifecycleIteration + */ + readonly rule: RelatedRule; /** * * @type {boolean} * @memberof LifecycleIteration */ readonly userCanReview: boolean; - /** - * - * @type {Array} - * @memberof LifecycleIteration - */ - readonly reviewerGroups: Array; - /** - * - * @type {number} - * @memberof LifecycleIteration - */ - readonly minReviewers: number; - /** - * - * @type {Array} - * @memberof LifecycleIteration - */ - readonly reviewers: Array; } /** @@ -130,10 +116,8 @@ export function instanceOfLifecycleIteration(value: object): value is LifecycleI if (!("gracePeriodEnd" in value) || value["gracePeriodEnd"] === undefined) return false; if (!("nextReviewDate" in value) || value["nextReviewDate"] === undefined) return false; if (!("reviews" in value) || value["reviews"] === undefined) return false; + if (!("rule" in value) || value["rule"] === undefined) return false; if (!("userCanReview" in value) || value["userCanReview"] === undefined) return false; - if (!("reviewerGroups" in value) || value["reviewerGroups"] === undefined) return false; - if (!("minReviewers" in value) || value["minReviewers"] === undefined) return false; - if (!("reviewers" in value) || value["reviewers"] === undefined) return false; return true; } @@ -159,10 +143,8 @@ export function LifecycleIterationFromJSONTyped( gracePeriodEnd: new Date(json["grace_period_end"]), nextReviewDate: new Date(json["next_review_date"]), reviews: (json["reviews"] as Array).map(ReviewFromJSON), + rule: RelatedRuleFromJSON(json["rule"]), userCanReview: json["user_can_review"], - reviewerGroups: (json["reviewer_groups"] as Array).map(ReviewerGroupFromJSON), - minReviewers: json["min_reviewers"], - reviewers: (json["reviewers"] as Array).map(ReviewerUserFromJSON), }; } @@ -182,10 +164,8 @@ export function LifecycleIterationToJSONTyped( | "grace_period_end" | "next_review_date" | "reviews" + | "rule" | "user_can_review" - | "reviewer_groups" - | "min_reviewers" - | "reviewers" > | null, ignoreDiscriminator: boolean = false, ): any { diff --git a/packages/client-ts/src/models/RelatedRule.ts b/packages/client-ts/src/models/RelatedRule.ts new file mode 100644 index 0000000000..3431cc814a --- /dev/null +++ b/packages/client-ts/src/models/RelatedRule.ts @@ -0,0 +1,103 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * authentik + * Making authentication simple. + * + * The version of the OpenAPI document: 2026.5.0-rc1 + * Contact: hello@goauthentik.io + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +import type { ReviewerGroup } from "./ReviewerGroup"; +import { ReviewerGroupFromJSON } from "./ReviewerGroup"; +import type { ReviewerUser } from "./ReviewerUser"; +import { ReviewerUserFromJSON } from "./ReviewerUser"; + +/** + * Mixin to validate that a valid enterprise license + * exists before allowing to save the object + * @export + * @interface RelatedRule + */ +export interface RelatedRule { + /** + * + * @type {string} + * @memberof RelatedRule + */ + id?: string; + /** + * + * @type {string} + * @memberof RelatedRule + */ + name: string; + /** + * + * @type {Array} + * @memberof RelatedRule + */ + readonly reviewerGroups: Array; + /** + * + * @type {number} + * @memberof RelatedRule + */ + readonly minReviewers: number; + /** + * + * @type {Array} + * @memberof RelatedRule + */ + readonly reviewers: Array; +} + +/** + * Check if a given object implements the RelatedRule interface. + */ +export function instanceOfRelatedRule(value: object): value is RelatedRule { + if (!("name" in value) || value["name"] === undefined) return false; + if (!("reviewerGroups" in value) || value["reviewerGroups"] === undefined) return false; + if (!("minReviewers" in value) || value["minReviewers"] === undefined) return false; + if (!("reviewers" in value) || value["reviewers"] === undefined) return false; + return true; +} + +export function RelatedRuleFromJSON(json: any): RelatedRule { + return RelatedRuleFromJSONTyped(json, false); +} + +export function RelatedRuleFromJSONTyped(json: any, ignoreDiscriminator: boolean): RelatedRule { + if (json == null) { + return json; + } + return { + id: json["id"] == null ? undefined : json["id"], + name: json["name"], + reviewerGroups: (json["reviewer_groups"] as Array).map(ReviewerGroupFromJSON), + minReviewers: json["min_reviewers"], + reviewers: (json["reviewers"] as Array).map(ReviewerUserFromJSON), + }; +} + +export function RelatedRuleToJSON(json: any): RelatedRule { + return RelatedRuleToJSONTyped(json, false); +} + +export function RelatedRuleToJSONTyped( + value?: Omit | null, + ignoreDiscriminator: boolean = false, +): any { + if (value == null) { + return value; + } + + return { + id: value["id"], + name: value["name"], + }; +} diff --git a/packages/client-ts/src/models/index.ts b/packages/client-ts/src/models/index.ts index 43ada952b1..31e0a3ef43 100644 --- a/packages/client-ts/src/models/index.ts +++ b/packages/client-ts/src/models/index.ts @@ -705,6 +705,7 @@ export * from "./RedirectURI"; export * from "./RedirectURIRequest"; export * from "./RedirectUriTypeEnum"; export * from "./RelatedGroup"; +export * from "./RelatedRule"; export * from "./Reputation"; export * from "./ReputationPolicy"; export * from "./ReputationPolicyRequest"; diff --git a/schema.yml b/schema.yml index 8667febd50..d37b4dc412 100644 --- a/schema.yml +++ b/schema.yml @@ -9132,7 +9132,7 @@ paths: $ref: '#/components/responses/GenericErrorResponse' /lifecycle/iterations/latest/{content_type}/{object_id}/: get: - operationId: lifecycle_iterations_latest_retrieve + operationId: lifecycle_iterations_list_latest description: |- Mixin to validate that a valid enterprise license exists before allowing to save the object @@ -9149,6 +9149,12 @@ paths: type: string pattern: ^[^/]+$ required: true + - $ref: '#/components/parameters/QueryPaginationOrdering' + - $ref: '#/components/parameters/QuerySearch' + - in: query + name: user_is_reviewer + schema: + type: boolean tags: - lifecycle security: @@ -9158,7 +9164,9 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/LifecycleIteration' + type: array + items: + $ref: '#/components/schemas/LifecycleIteration' description: '' '400': $ref: '#/components/responses/ValidationErrorResponse' @@ -42426,35 +42434,24 @@ components: items: $ref: '#/components/schemas/Review' readOnly: true + rule: + allOf: + - $ref: '#/components/schemas/RelatedRule' + 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 + - rule - state - user_can_review LifecycleIterationRequest: @@ -53285,6 +53282,35 @@ components: - group_uuid - name - pk + RelatedRule: + type: object + description: |- + Mixin to validate that a valid enterprise license + exists before allowing to save the object + properties: + id: + type: string + format: uuid + name: + type: string + 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: + - min_reviewers + - name + - reviewer_groups + - reviewers Reputation: type: object description: Reputation Serializer diff --git a/web/src/admin/lifecycle/LifecycleRuleForm.ts b/web/src/admin/lifecycle/LifecycleRuleForm.ts index 63e107bf7d..ef97f191d7 100644 --- a/web/src/admin/lifecycle/LifecycleRuleForm.ts +++ b/web/src/admin/lifecycle/LifecycleRuleForm.ts @@ -234,7 +234,7 @@ export class LifecycleRuleForm extends ModelForm each of the selected groups. When disabled, the value is a total diff --git a/web/src/admin/lifecycle/LifecycleRuleListPage.ts b/web/src/admin/lifecycle/LifecycleRuleListPage.ts index bc5e035328..5ea99420f0 100644 --- a/web/src/admin/lifecycle/LifecycleRuleListPage.ts +++ b/web/src/admin/lifecycle/LifecycleRuleListPage.ts @@ -26,7 +26,6 @@ import { customElement } from "lit/decorators.js"; @customElement("ak-lifecycle-rule-list") export class LifecycleRuleListPage extends TablePage { - public override expandable = true; public override checkbox = true; public override clearOnRefresh = true; public override searchPlaceholder = msg("Search for a lifecycle rule by name or target..."); @@ -95,26 +94,6 @@ export class LifecycleRuleListPage extends TablePage { ]; } - protected override renderExpanded(item: LifecycleRule): SlottedTemplateResult { - const [appLabel, modelName] = ModelEnum.AuthentikLifecycleLifecyclerule.split("."); - return html`
-
-
- ${msg("Tasks")} -
-
-
- -
-
-
-
`; - } protected override renderObjectCreate(): SlottedTemplateResult { return ModalInvokerButton(LifecycleRuleForm); } diff --git a/web/src/admin/lifecycle/ObjectLifecyclePage.ts b/web/src/admin/lifecycle/ObjectLifecyclePage.ts index cc665ee8dc..0faa010a19 100644 --- a/web/src/admin/lifecycle/ObjectLifecyclePage.ts +++ b/web/src/admin/lifecycle/ObjectLifecyclePage.ts @@ -1,53 +1,39 @@ import "#admin/lifecycle/LifecyclePreviewBanner"; -import "#components/ak-textarea-input"; -import "#elements/forms/ModalForm"; -import "#elements/timestamp/ak-timestamp"; -import "#admin/lifecycle/ObjectReviewForm"; +import "#admin/lifecycle/ObjectReviewIteration"; import { DEFAULT_CONFIG } from "#common/api/config"; -import { createPaginatedResponse } from "#common/api/responses"; +import { EVENT_REFRESH } from "#common/constants"; import { isResponseErrorLike } from "#common/errors/network"; -import { ModalInvokerButton } from "#elements/dialogs"; -import { PaginatedResponse, Table, TableColumn, Timestamp } from "#elements/table/Table"; +import { AKElement } from "#elements/Base"; +import { WithLicenseSummary } from "#elements/mixins/license"; +import { WithSession } from "#elements/mixins/session"; +import Styles from "#elements/table/Table.css"; import { SlottedTemplateResult } from "#elements/types"; import { ifPreviousValue } from "#elements/utils/properties"; -import { ObjectReviewForm } from "#admin/lifecycle/ObjectReviewForm"; -import { LifecycleIterationStatus } from "#admin/lifecycle/utils"; +import { ContentTypeEnum, LifecycleApi, LifecycleIteration } from "@goauthentik/api"; -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 { msg } from "@lit/localize"; +import { html } 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 PFPage from "@patternfly/patternfly/components/Page/page.css"; -import PFFlex from "@patternfly/patternfly/layouts/Flex/flex.css"; +import PFTitle from "@patternfly/patternfly/components/Title/title.css"; import PFGrid from "@patternfly/patternfly/layouts/Grid/grid.css"; +import PFSpacing from "@patternfly/patternfly/utilities/Spacing/spacing.css"; @customElement("ak-object-lifecycle-page") -export class ObjectLifecyclePage extends Table { +export class ObjectLifecyclePage extends WithLicenseSummary(WithSession(AKElement)) { static styles = [ // --- - ...super.styles, + PFTitle, PFGrid, PFBanner, - PFCard, - PFFlex, + Styles, + PFSpacing, PFPage, - PFDescriptionList, ]; //#region Public Properties @@ -58,237 +44,67 @@ export class ObjectLifecyclePage extends Table { @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 iterations: LifecycleIteration[] | null = null; - protected apiEndpoint(): Promise> { + #refreshListener = () => { + return this.fetch(); + }; + + public override connectedCallback(): void { + super.connectedCallback(); + this.addEventListener(EVENT_REFRESH, this.#refreshListener); + } + + public async fetch(): Promise { if (!this.model || !this.objectPk) { - return Promise.resolve(createPaginatedResponse()); + return Promise.resolve(); } - return new LifecycleApi(DEFAULT_CONFIG) - .lifecycleIterationsLatestRetrieve({ + .lifecycleIterationsListLatest({ contentType: this.model, objectId: String(this.objectPk), }) - .then((iteration) => { - this.iteration = iteration; - - return createPaginatedResponse(iteration.reviews); + .then((iterations) => { + this.iterations = iterations; }) .catch(async (error: unknown) => { if (isResponseErrorLike(error) && error.response.status === 404) { - this.iteration = null; - - return createPaginatedResponse(); + this.iterations = null; } - throw error; }); } - protected updated(changedProperties: PropertyValues): 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`${msg("No review iteration found for this object.")}`; - } - - const { reviewers, reviewerGroups, minReviewers } = this.iteration; - - const result: TemplateResult[] = []; - - if (reviewers.length) { - result.push(html`
${reviewers.map((u) => u.name).join(", ")}
`); - } - - 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`
${label}
`); - - return result; - } - - protected renderOpenedOn(): SlottedTemplateResult { - return html`
-
- ${msg("Review opened on")} -
-
-
- -
-
-
`; - } - - protected renderGracePeriodTill(): SlottedTemplateResult { - return html`
-
- ${msg("Grace period till")} -
-
-
- -
-
-
`; - } - - protected renderNextReviewDate(): SlottedTemplateResult { - return html`
-
- ${msg("Next review date")} -
-
-
- -
-
-
`; - } - - 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`
-
${msg("Latest review for this object")}
-
-
-
-
- ${msg("Review state")} -
-
-
- ${LifecycleIterationStatus({ - status: this.iteration?.state, - })} -
-
-
- -
-
- ${msg("Required reviewers")} -
-
-
${this.renderReviewers()}
-
-
- ${this.renderReviewDates()} -
-
-
`; - } - - //#endregion - - //#region Table - - protected row(item: Review): SlottedTemplateResult[] { - return [ - Timestamp(item.timestamp), - html`${item.reviewer.name}`, - html`${item.note}`, - ]; - } - - protected override renderEmpty(): SlottedTemplateResult { - return super.renderEmpty( - html`${this.emptyStateMessage}`, - ); - } - - protected renderObjectCreate(): SlottedTemplateResult { - if (!this.iteration?.userCanReview) { - return null; - } - - return ModalInvokerButton(ObjectReviewForm, { - iteration: this.iteration, - }); - } - protected override render(): SlottedTemplateResult { - return html` -
-
- ${this.renderReviewSummary()} -
-
${msg("Reviews")}
- ${super.render()} -
-
+ return html` + +
+

+ ${this.iterations?.length + ? msg("The following reviews apply to this object:") + : msg("This object has no reviews yet.")} +

+ ${this.iterations?.map( + (i) => + html`

${i.rule.name}

+ `, + )}
-
`; + `; } //#endregion diff --git a/web/src/admin/lifecycle/ObjectReviewIteration.ts b/web/src/admin/lifecycle/ObjectReviewIteration.ts new file mode 100644 index 0000000000..e1316c5a00 --- /dev/null +++ b/web/src/admin/lifecycle/ObjectReviewIteration.ts @@ -0,0 +1,281 @@ +import "#admin/lifecycle/LifecyclePreviewBanner"; +import "#components/ak-textarea-input"; +import "#elements/forms/ModalForm"; +import "#elements/timestamp/ak-timestamp"; +import "#admin/lifecycle/ObjectReviewForm"; + +import { createPaginatedResponse } from "#common/api/responses"; +import { EVENT_REFRESH } from "#common/constants"; + +import { ModalInvokerButton } from "#elements/dialogs"; +import { PaginatedResponse, Table, TableColumn, Timestamp } from "#elements/table/Table"; +import { SlottedTemplateResult } from "#elements/types"; + +import { ObjectReviewForm } from "#admin/lifecycle/ObjectReviewForm"; +import { LifecycleIterationStatus } from "#admin/lifecycle/utils"; + +import { 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 } 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-review-iteration") +export class ObjectReviewIteration extends Table { + static styles = [ + // --- + ...super.styles, + PFGrid, + PFBanner, + PFCard, + PFFlex, + PFDescriptionList, + ]; + + //#region Public Properties + + @property({ attribute: false }) + public iteration: LifecycleIteration | 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 + + protected updated(changedProperties: PropertyValues): void { + super.updated(changedProperties); + + if (changedProperties.has("iteration")) { + this.fetch(); + } + } + + protected apiEndpoint(): Promise> { + if (!this.iteration) { + return Promise.resolve(createPaginatedResponse()); + } + + return Promise.resolve(createPaginatedResponse(this.iteration.reviews)); + } + + #triggerRefresh = () => { + this.dispatchEvent( + new CustomEvent(EVENT_REFRESH, { + bubbles: true, + composed: true, + }), + ); + }; + + //#endregion + + //#region Rendering + + //#region Summary Card + + protected renderReviewers(): SlottedTemplateResult { + if (!this.iteration) { + return html`${msg("No review iteration found for this object.")}`; + } + + const { reviewers, reviewerGroups, minReviewers } = this.iteration.rule; + + const result: TemplateResult[] = []; + + if (reviewers.length) { + result.push(html`
${reviewers.map((u) => u.name).join(", ")}
`); + } + + 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`
${label}
`); + + return result; + } + + protected renderOpenedOn(): SlottedTemplateResult { + return html`
+
+ ${msg("Review opened on")} +
+
+
+ +
+
+
`; + } + + protected renderGracePeriodTill(): SlottedTemplateResult { + return html`
+
+ ${msg("Grace period till")} +
+
+
+ +
+
+
`; + } + + protected renderNextReviewDate(): SlottedTemplateResult { + return html`
+
+ ${msg("Next review date")} +
+
+
+ +
+
+
`; + } + + 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`
+
${msg("Latest review for this object")}
+
+
+
+
+ ${msg("Review state")} +
+
+
+ ${LifecycleIterationStatus({ + status: this.iteration?.state, + })} +
+
+
+ +
+
+ ${msg("Required reviewers")} +
+
+
${this.renderReviewers()}
+
+
+ ${this.renderReviewDates()} +
+
+
`; + } + + //#endregion + + //#region Table + + protected renderToolbar(): SlottedTemplateResult { + return html`${this.renderObjectCreate()} + + ${msg("Refresh")} + `; + } + + protected row(item: Review): SlottedTemplateResult[] { + return [ + Timestamp(item.timestamp), + html`${item.reviewer.name}`, + html`${item.note}`, + ]; + } + + protected override renderEmpty(): SlottedTemplateResult { + return super.renderEmpty( + html` ${this.emptyStateMessage}`, + ); + } + + protected renderObjectCreate(): SlottedTemplateResult { + if (!this.iteration?.userCanReview) { + return null; + } + + return ModalInvokerButton(ObjectReviewForm, { + iteration: this.iteration, + }); + } + + protected override render(): SlottedTemplateResult { + return html`
+ ${this.renderReviewSummary()} +
+
${msg("Reviews")}
+
${super.render()}
+
+
`; + } + + //#endregion + + //#endregion +} + +declare global { + interface HTMLElementTagNameMap { + "ak-object-review-iteration": ObjectReviewIteration; + } +} diff --git a/web/src/admin/lifecycle/ReviewListPage.ts b/web/src/admin/lifecycle/ReviewListPage.ts index 6c1cc2e715..2666dcb577 100644 --- a/web/src/admin/lifecycle/ReviewListPage.ts +++ b/web/src/admin/lifecycle/ReviewListPage.ts @@ -70,6 +70,7 @@ export class ReviewListPage extends TablePage { protected columns: TableColumn[] = [ [msg("State"), "state"], [msg("Object"), "content_type__model"], + [msg("Rule"), "rule__name"], [msg("Opened"), "opened_on"], [msg("Grace period ends")], ]; @@ -78,6 +79,7 @@ export class ReviewListPage extends TablePage { return [ LifecycleIterationStatus({ status: item.state }), html`${item.objectVerbose}`, + html`${item.rule.name}`, html``, html``, ]; diff --git a/website/docs/sys-mgmt/object-lifecycle-management.md b/website/docs/sys-mgmt/object-lifecycle-management.md index 283902bfe8..4334d22495 100644 --- a/website/docs/sys-mgmt/object-lifecycle-management.md +++ b/website/docs/sys-mgmt/object-lifecycle-management.md @@ -22,9 +22,9 @@ You can create and configure Lifecycle rules via the **Events** > **Lifecycle Ru 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. +- **An entire object type**: The rule applies to all objects of that type (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. +Multiple rules can apply to the same object. For example, you can have a type-level rule that schedules quarterly reviews for all applications and an object-specific rule that schedules monthly reviews for a critical application. Each rule creates its own independent review cycle, so the object may have multiple concurrent reviews visible on its **Lifecycle** tab. ### Rule settings @@ -44,7 +44,7 @@ A lifecycle rule has the following settings: ### Reviewer requirements -An object's review is considered complete when all of the following conditions are met: +Each rule's review is considered complete independently. A 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). @@ -58,9 +58,9 @@ For example, if a rule has: 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 +## Review states -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. +Each lifecycle rule creates its own review for the objects it governs. When multiple rules apply to the same object, each rule's review has its own independent state and progresses through its own review cycle. You can view all pending or overdue reviews on the **Events** > **Reviews** page. You can also view all of an object's current reviews on the **Lifecycle** tab of the object's detail page. | State | Description | | ------------ | -------------------------------------------------------------------------------------- | @@ -73,32 +73,34 @@ Each object governed by a lifecycle rule has a review state. You can view all ob 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. +1. When a lifecycle rule is created or when the interval since the last completed review has elapsed, a new **Pending** review is created for the object and the rule's 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. +3. After all of the rule's requirements are met, the review transitions to the **Reviewed** state. +4. If the grace period passes without all requirements being met, the review becomes **Overdue** and reviewers receive an alert. +5. After the interval passes, a new review cycle begins for that rule. + +If multiple rules apply to the same object, each rule runs its own review cycle independently. An object can have multiple concurrent reviews, each tracked separately on the **Lifecycle** tab. ## 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. +To review and approve an object for a 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). +1. Once a new review cycle starts, 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. + Alternatively, you can navigate to the **Events** > **Reviews** page and enable "Only show reviews where I am a reviewer" filter to see reviews awaiting your action. 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. + In both cases, you will be taken to the **Lifecycle** tab of the object's detail page, which lists all active reviews for the object. 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. +5. Find the review for the relevant rule and click **Review** to submit your review, optionally including a note. +6. Once all of the rule's reviewer requirements are met, that review 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. +When a review is in the **Pending** or **Overdue** state, authorized reviewers can submit their approval. Each reviewer can only submit one review per rule per review cycle. When submitting a review, reviewers can optionally include a note explaining their decision. Only authorized reviewers can submit reviews: