Compare commits

...

5 Commits

Author SHA1 Message Date
dependabot[bot]
d40b485890 ci: bump taiki-e/install-action in /.github/actions/setup
Bumps [taiki-e/install-action](https://github.com/taiki-e/install-action) from 2.75.23 to 2.75.25.
- [Release notes](https://github.com/taiki-e/install-action/releases)
- [Changelog](https://github.com/taiki-e/install-action/blob/main/CHANGELOG.md)
- [Commits](481c34c1cf...1329c298aa)

---
updated-dependencies:
- dependency-name: taiki-e/install-action
  dependency-version: 2.75.25
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-01 04:40:21 +00:00
Dominic R
821b74d7c1 enterprise: account lockdown (#18615) 2026-04-30 23:02:46 +00:00
Alexander Tereshkin
8963d29ab4 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 <dominic@sdko.org>
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 <dominic@sdko.org>
2026-04-30 14:11:07 -05:00
dependabot[bot]
699360064e web: bump knip from 6.6.0 to 6.6.3 in /web (#21981)
Bumps [knip](https://github.com/webpro-nl/knip/tree/HEAD/packages/knip) from 6.6.0 to 6.6.3.
- [Release notes](https://github.com/webpro-nl/knip/releases)
- [Commits](https://github.com/webpro-nl/knip/commits/knip@6.6.3/packages/knip)

---
updated-dependencies:
- dependency-name: knip
  dependency-version: 6.6.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-30 17:45:56 +02:00
Marc 'risson' Schmitt
3f94f830fc packages/ak-common/tracing: make log level lowercase (#21991) 2026-04-30 14:58:10 +00:00
88 changed files with 5243 additions and 623 deletions

View File

@@ -64,7 +64,7 @@ runs:
rustflags: ""
- name: Setup rust dependencies
if: ${{ contains(inputs.dependencies, 'rust') }}
uses: taiki-e/install-action@481c34c1cf3a84c68b5e46f4eccfc82af798415a # v2
uses: taiki-e/install-action@1329c298aa20c3257846c9b2e0e55967df3e3c37 # v2
with:
tool: cargo-deny cargo-machete cargo-llvm-cov nextest
- name: Setup node (web)
@@ -104,7 +104,7 @@ runs:
working-directory: ${{ inputs.working-directory }}
run: |
export PSQL_TAG=${{ inputs.postgresql_version }}
docker compose -f .github/actions/setup/compose.yml up -d
docker compose -f .github/actions/setup/compose.yml up -d --wait
cd web && npm ci
- name: Generate config
if: ${{ contains(inputs.dependencies, 'python') }}

View File

@@ -8,8 +8,14 @@ services:
POSTGRES_USER: authentik
POSTGRES_PASSWORD: "EK-5jnKfjrGRm<77"
POSTGRES_DB: authentik
PGDATA: /var/lib/postgresql/data/pgdata
ports:
- 5432:5432
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB} -h 127.0.0.1"]
interval: 1s
timeout: 5s
retries: 60
restart: always
s3:
container_name: s3

View File

@@ -126,7 +126,7 @@ class BlueprintInstanceSerializer(ModelSerializer):
def check_blueprint_perms(blueprint: Blueprint, user: User, explicit_action: str | None = None):
"""Check for individual permissions for each model in a blueprint"""
for entry in blueprint.entries:
for entry in blueprint.iter_entries():
full_model = entry.get_model(blueprint)
app, __, model = full_model.partition(".")
perms = [

View File

@@ -1,8 +1,11 @@
"""Test blueprints v1"""
from unittest.mock import patch
from django.test import TransactionTestCase
from authentik.blueprints.v1.importer import Importer
from authentik.enterprise.license import LicenseKey
from authentik.flows.models import Flow
from authentik.lib.generators import generate_id
from authentik.lib.tests.utils import load_fixture
@@ -42,3 +45,45 @@ class TestBlueprintsV1Conditions(TransactionTestCase):
# Ensure objects do not exist
self.assertFalse(Flow.objects.filter(slug=flow_slug1))
self.assertFalse(Flow.objects.filter(slug=flow_slug2))
def test_enterprise_license_context_unlicensed(self):
"""Test enterprise license context defaults to a false boolean when unlicensed."""
license_key = LicenseKey("test", 0, "Test license", 0, 0)
with patch("authentik.enterprise.license.LicenseKey.get_total", return_value=license_key):
importer = Importer.from_string("""
version: 1
entries:
- identifiers:
name: enterprise-test
slug: enterprise-test
model: authentik_flows.flow
conditions:
- !Context goauthentik.io/enterprise/licensed
attrs:
designation: stage_configuration
title: foo
""")
self.assertIs(importer.blueprint.context["goauthentik.io/enterprise/licensed"], False)
def test_enterprise_license_context_licensed(self):
"""Test enterprise license context defaults to a true boolean when licensed."""
license_key = LicenseKey("test", 253402300799, "Test license", 1000, 1000)
with patch("authentik.enterprise.license.LicenseKey.get_total", return_value=license_key):
importer = Importer.from_string("""
version: 1
entries:
- identifiers:
name: enterprise-test
slug: enterprise-test
model: authentik_flows.flow
conditions:
- !Context goauthentik.io/enterprise/licensed
attrs:
designation: stage_configuration
title: foo
""")
self.assertIs(importer.blueprint.context["goauthentik.io/enterprise/licensed"], True)

View File

@@ -146,9 +146,7 @@ class Importer:
try:
from authentik.enterprise.license import LicenseKey
context["goauthentik.io/enterprise/licensed"] = (
LicenseKey.get_total().status().is_valid,
)
context["goauthentik.io/enterprise/licensed"] = LicenseKey.get_total().status().is_valid
except ModuleNotFoundError:
pass
return context

View File

@@ -64,6 +64,7 @@ class BrandSerializer(ModelSerializer):
"flow_unenrollment",
"flow_user_settings",
"flow_device_code",
"flow_lockdown",
"default_application",
"web_certificate",
"client_certificates",
@@ -117,6 +118,7 @@ class CurrentBrandSerializer(PassiveSerializer):
flow_unenrollment = CharField(source="flow_unenrollment.slug", required=False)
flow_user_settings = CharField(source="flow_user_settings.slug", required=False)
flow_device_code = CharField(source="flow_device_code.slug", required=False)
flow_lockdown = CharField(source="flow_lockdown.slug", required=False)
default_locale = CharField(read_only=True)
flags = SerializerMethodField()
@@ -154,6 +156,7 @@ class BrandViewSet(UsedByMixin, ModelViewSet):
"flow_unenrollment",
"flow_user_settings",
"flow_device_code",
"flow_lockdown",
"web_certificate",
"client_certificates",
]

View File

@@ -0,0 +1,25 @@
# Generated by Django 5.2.12 on 2026-03-14 02:58
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("authentik_brands", "0011_alter_brand_branding_default_flow_background_and_more"),
("authentik_flows", "0031_alter_flow_layout"),
]
operations = [
migrations.AddField(
model_name="brand",
name="flow_lockdown",
field=models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="brand_lockdown",
to="authentik_flows.flow",
),
),
]

View File

@@ -58,6 +58,9 @@ class Brand(SerializerModel):
flow_device_code = models.ForeignKey(
Flow, null=True, on_delete=models.SET_NULL, related_name="brand_device_code"
)
flow_lockdown = models.ForeignKey(
Flow, null=True, on_delete=models.SET_NULL, related_name="brand_lockdown"
)
default_application = models.ForeignKey(
"authentik_core.Application",

View File

@@ -563,6 +563,9 @@ class UsersFilter(FilterSet):
class UserViewSet(
ConditionalInheritance(
"authentik.enterprise.stages.account_lockdown.api.UserAccountLockdownMixin"
),
ConditionalInheritance("authentik.enterprise.reports.api.reports.ExportMixin"),
UsedByMixin,
ModelViewSet,

View File

@@ -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<content_type>[^/]+)/(?P<object_id>[^/]+)",
)
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(

View File

@@ -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

View File

@@ -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(),
),
]

View File

@@ -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()

View File

@@ -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"
),
)

View File

@@ -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"
),
)

View File

@@ -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(),

View File

@@ -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)

View File

@@ -14,6 +14,7 @@ TENANT_APPS = [
"authentik.enterprise.providers.ssf",
"authentik.enterprise.providers.ws_federation",
"authentik.enterprise.reports",
"authentik.enterprise.stages.account_lockdown",
"authentik.enterprise.stages.authenticator_endpoint_gdtc",
"authentik.enterprise.stages.mtls",
"authentik.enterprise.stages.source",

View File

@@ -0,0 +1,141 @@
"""Account Lockdown Stage API Views"""
from django.utils.translation import gettext as _
from drf_spectacular.utils import OpenApiExample, OpenApiResponse, extend_schema
from rest_framework.decorators import action
from rest_framework.exceptions import ValidationError
from rest_framework.permissions import IsAuthenticated
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.serializers import PrimaryKeyRelatedField
from rest_framework.viewsets import ModelViewSet
from structlog.stdlib import get_logger
from authentik.api.validation import validate
from authentik.core.api.used_by import UsedByMixin
from authentik.core.api.utils import LinkSerializer, PassiveSerializer
from authentik.core.models import (
User,
)
from authentik.enterprise.api import EnterpriseRequiredMixin, enterprise_action
from authentik.enterprise.stages.account_lockdown.models import AccountLockdownStage
from authentik.enterprise.stages.account_lockdown.stage import (
can_lock_user,
get_lockdown_target_users,
)
from authentik.flows.api.stages import StageSerializer
from authentik.flows.exceptions import EmptyFlowException, FlowNonApplicableException
from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlanner
LOGGER = get_logger()
class AccountLockdownStageSerializer(EnterpriseRequiredMixin, StageSerializer):
"""AccountLockdownStage Serializer"""
class Meta:
model = AccountLockdownStage
fields = StageSerializer.Meta.fields + [
"deactivate_user",
"set_unusable_password",
"delete_sessions",
"revoke_tokens",
"self_service_completion_flow",
]
class AccountLockdownStageViewSet(UsedByMixin, ModelViewSet):
"""AccountLockdownStage Viewset"""
queryset = AccountLockdownStage.objects.all()
serializer_class = AccountLockdownStageSerializer
filterset_fields = "__all__"
ordering = ["name"]
search_fields = ["name"]
class UserAccountLockdownSerializer(PassiveSerializer):
"""Choose the target account before starting the lockdown flow."""
user = PrimaryKeyRelatedField(
queryset=get_lockdown_target_users(),
required=False,
allow_null=True,
help_text=_("User to lock. If omitted, locks the current user (self-service)."),
)
class UserAccountLockdownMixin:
"""Enterprise account-lockdown API actions for UserViewSet."""
def _create_lockdown_flow_url(self, request: Request, user: User) -> str:
"""Create a flow URL for account lockdown.
The request body selects the target before the flow starts. The API
pre-plans the lockdown flow with the target as the pending user, so the
account lockdown stage can use the normal flow context.
"""
flow = request._request.brand.flow_lockdown
if flow is None:
raise ValidationError({"non_field_errors": [_("No lockdown flow configured.")]})
planner = FlowPlanner(flow)
planner.use_cache = False
try:
plan = planner.plan(request._request, {PLAN_CONTEXT_PENDING_USER: user})
except EmptyFlowException, FlowNonApplicableException:
raise ValidationError(
{"non_field_errors": [_("Lockdown flow is not applicable.")]}
) from None
return plan.to_redirect(request._request, flow).url
@extend_schema(
description=_("Choose the target account, then return a flow link."),
request=UserAccountLockdownSerializer,
responses={
"200": OpenApiResponse(
response=LinkSerializer,
examples=[
OpenApiExample(
"Lockdown flow URL",
value={
"link": "https://example.invalid/if/flow/default-account-lockdown/",
},
response_only=True,
status_codes=["200"],
)
],
),
"400": OpenApiResponse(
description=_("No lockdown flow configured or the flow is not applicable")
),
"403": OpenApiResponse(
description=_("Permission denied (when targeting another user)")
),
},
)
@action(
detail=False,
methods=["POST"],
permission_classes=[IsAuthenticated],
url_path="account_lockdown",
)
@validate(UserAccountLockdownSerializer)
@enterprise_action
def account_lockdown(self, request: Request, body: UserAccountLockdownSerializer) -> Response:
"""Trigger account lockdown for a user.
If no user is specified, locks the current user (self-service).
When targeting another user, admin permissions are required.
Returns a flow link for the frontend to follow. The flow is pre-planned
with the target user as pending user for the lockdown stage.
"""
user = body.validated_data.get("user") or request.user
if not can_lock_user(request.user, user):
LOGGER.debug("Permission denied for account lockdown", user=request.user)
self.permission_denied(request)
flow_url = self._create_lockdown_flow_url(request, user)
LOGGER.debug("Returning lockdown flow URL", flow_url=flow_url, user=user.username)
return Response({"link": flow_url})

View File

@@ -0,0 +1,12 @@
"""authentik account lockdown stage app config"""
from authentik.enterprise.apps import EnterpriseConfig
class AuthentikEnterpriseStageAccountLockdownConfig(EnterpriseConfig):
"""authentik account lockdown stage config"""
name = "authentik.enterprise.stages.account_lockdown"
label = "authentik_stages_account_lockdown"
verbose_name = "authentik Enterprise.Stages.Account Lockdown"
default = True

View File

@@ -0,0 +1,74 @@
# Generated by Django 5.2.13 on 2026-04-19 21:56
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
("authentik_flows", "0031_alter_flow_layout"),
]
operations = [
migrations.CreateModel(
name="AccountLockdownStage",
fields=[
(
"stage_ptr",
models.OneToOneField(
auto_created=True,
on_delete=django.db.models.deletion.CASCADE,
parent_link=True,
primary_key=True,
serialize=False,
to="authentik_flows.stage",
),
),
(
"deactivate_user",
models.BooleanField(
default=True,
help_text="Deactivate the user account (set is_active to False)",
),
),
(
"set_unusable_password",
models.BooleanField(
default=True, help_text="Set an unusable password for the user"
),
),
(
"delete_sessions",
models.BooleanField(
default=True, help_text="Delete all active sessions for the user"
),
),
(
"revoke_tokens",
models.BooleanField(
default=True,
help_text="Revoke all tokens for the user (API, app password, recovery, verification, OAuth)",
),
),
(
"self_service_completion_flow",
models.ForeignKey(
blank=True,
help_text="Flow to redirect users to after self-service lockdown. This flow should not require authentication since the user's session is deleted.",
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="account_lockdown_stages",
to="authentik_flows.flow",
),
),
],
options={
"verbose_name": "Account Lockdown Stage",
"verbose_name_plural": "Account Lockdown Stages",
},
bases=("authentik_flows.stage",),
),
]

View File

@@ -0,0 +1,62 @@
"""Account lockdown stage models"""
from django.db import models
from django.utils.translation import gettext_lazy as _
from django.views import View
from rest_framework.serializers import BaseSerializer
from authentik.flows.models import Stage
class AccountLockdownStage(Stage):
"""Lock down a target user account."""
deactivate_user = models.BooleanField(
default=True,
help_text=_("Deactivate the user account (set is_active to False)"),
)
set_unusable_password = models.BooleanField(
default=True,
help_text=_("Set an unusable password for the user"),
)
delete_sessions = models.BooleanField(
default=True,
help_text=_("Delete all active sessions for the user"),
)
revoke_tokens = models.BooleanField(
default=True,
help_text=_(
"Revoke all tokens for the user (API, app password, recovery, verification, OAuth)"
),
)
self_service_completion_flow = models.ForeignKey(
"authentik_flows.Flow",
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="account_lockdown_stages",
help_text=_(
"Flow to redirect users to after self-service lockdown. "
"This flow should not require authentication since the user's session is deleted."
),
)
@property
def serializer(self) -> type[BaseSerializer]:
from authentik.enterprise.stages.account_lockdown.api import AccountLockdownStageSerializer
return AccountLockdownStageSerializer
@property
def view(self) -> type[View]:
from authentik.enterprise.stages.account_lockdown.stage import AccountLockdownStageView
return AccountLockdownStageView
@property
def component(self) -> str:
return "ak-stage-account-lockdown-form"
class Meta:
verbose_name = _("Account Lockdown Stage")
verbose_name_plural = _("Account Lockdown Stages")

View File

@@ -0,0 +1,345 @@
"""Account lockdown stage logic"""
from django.apps import apps
from django.core.exceptions import FieldDoesNotExist
from django.db.models import Model, QuerySet
from django.db.models.query_utils import Q
from django.db.transaction import atomic
from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from dramatiq.actor import Actor
from dramatiq.composition import group
from dramatiq.results.errors import ResultTimeout
from authentik.core.models import (
AuthenticatedSession,
ExpiringModel,
Session,
Token,
User,
UserTypes,
)
from authentik.enterprise.stages.account_lockdown.models import AccountLockdownStage
from authentik.events.models import Event, EventAction
from authentik.flows.stage import StageView
from authentik.lib.sync.outgoing.models import OutgoingSyncProvider
from authentik.lib.sync.outgoing.signals import sync_outgoing_inhibit_dispatch
from authentik.lib.utils.reflection import class_to_path
from authentik.lib.utils.time import timedelta_from_string
from authentik.stages.prompt.stage import PLAN_CONTEXT_PROMPT
PLAN_CONTEXT_LOCKDOWN_REASON = "lockdown_reason"
LOCKDOWN_EVENT_ACTION_ID = "account_lockdown"
TARGET_REQUIRED_MESSAGE = _("No target user specified for account lockdown")
PERMISSION_DENIED_MESSAGE = _("You do not have permission to lock down this account.")
ACCOUNT_LOCKDOWN_FAILED_MESSAGE = _("Account lockdown failed for this account.")
SELF_SERVICE_COMPLETION_FLOW_REQUIRED_MESSAGE = _(
"Self-service account lockdown requires a completion flow."
)
def get_lockdown_target_users() -> QuerySet[User]:
"""Return users that can be targeted by account lockdown."""
return User.objects.exclude_anonymous().exclude(type=UserTypes.INTERNAL_SERVICE_ACCOUNT)
def _get_model_field(model: type[Model], field_name: str):
"""Get a model field by name, if present."""
try:
return model._meta.get_field(field_name)
except FieldDoesNotExist:
return None
def _has_user_field(model: type[Model]) -> bool:
"""Check if a model has a direct user foreign key."""
field = _get_model_field(model, "user")
return bool(field and getattr(field, "remote_field", None) and field.remote_field.model is User)
def _has_authenticated_session_field(model: type[Model]) -> bool:
"""Check if a model is linked to an authenticated session."""
field = _get_model_field(model, "session")
return bool(
field
and getattr(field, "remote_field", None)
and field.remote_field.model is AuthenticatedSession
)
def _has_provider_field(model: type[Model]) -> bool:
"""Check if a model is linked to a provider."""
return _get_model_field(model, "provider") is not None
def get_lockdown_token_models() -> tuple[type[Model], ...]:
"""Return token, grant, and provider session models removed by account lockdown."""
token_models: list[type[Model]] = []
for model in apps.get_models():
if model._meta.abstract or not issubclass(model, ExpiringModel):
continue
if model is Token:
token_models.append(model)
elif _has_user_field(model) and (
_has_provider_field(model) or _has_authenticated_session_field(model)
):
token_models.append(model)
elif _has_authenticated_session_field(model):
token_models.append(model)
return tuple(token_models)
def get_lockdown_token_queryset(model: type[Model], user: User) -> QuerySet:
"""Return account lockdown artifacts for a model and user."""
manager = model.objects.including_expired()
if _has_user_field(model):
return manager.filter(user=user)
return manager.filter(session__user=user)
def can_lock_user(actor, user: User) -> bool:
"""Check whether the actor may lock the target user."""
if not actor.is_authenticated:
return False
if user.pk == actor.pk:
return True
return actor.has_perm("authentik_core.change_user", user)
def get_outgoing_sync_tasks() -> tuple[tuple[type[OutgoingSyncProvider], Actor], ...]:
"""Return outgoing sync provider types and their direct sync tasks."""
from authentik.enterprise.providers.google_workspace.models import GoogleWorkspaceProvider
from authentik.enterprise.providers.google_workspace.tasks import google_workspace_sync_direct
from authentik.enterprise.providers.microsoft_entra.models import MicrosoftEntraProvider
from authentik.enterprise.providers.microsoft_entra.tasks import microsoft_entra_sync_direct
from authentik.providers.scim.models import SCIMProvider
from authentik.providers.scim.tasks import scim_sync_direct
return (
(SCIMProvider, scim_sync_direct),
(GoogleWorkspaceProvider, google_workspace_sync_direct),
(MicrosoftEntraProvider, microsoft_entra_sync_direct),
)
class AccountLockdownStageView(StageView):
"""Execute account lockdown actions on the target user."""
def is_self_service(self, request: HttpRequest, user: User) -> bool:
"""Check whether the currently authenticated user is locking their own account."""
return request.user.is_authenticated and user.pk == request.user.pk
def get_reason(self) -> str:
"""Get the lockdown reason from the plan context.
Priority:
1. prompt_data[PLAN_CONTEXT_LOCKDOWN_REASON]
2. PLAN_CONTEXT_LOCKDOWN_REASON (explicitly set)
3. Empty string as fallback
"""
prompt_data = self.executor.plan.context.get(PLAN_CONTEXT_PROMPT, {})
if PLAN_CONTEXT_LOCKDOWN_REASON in prompt_data:
return prompt_data[PLAN_CONTEXT_LOCKDOWN_REASON]
return self.executor.plan.context.get(PLAN_CONTEXT_LOCKDOWN_REASON, "")
def _apply_lockdown_actions(self, stage: AccountLockdownStage, user: User) -> None:
"""Apply the configured account changes to the target user."""
if stage.deactivate_user:
user.is_active = False
if stage.set_unusable_password:
user.set_unusable_password()
if stage.deactivate_user:
with sync_outgoing_inhibit_dispatch():
user.save()
return
user.save()
def _sync_deactivated_user_to_outgoing_providers(self, user: User) -> None:
"""Synchronize a deactivated user to outgoing sync providers."""
messages = []
wait_timeout = 0
model = class_to_path(User)
provider_filter = Q(backchannel_application__isnull=False) | Q(application__isnull=False)
for provider_model, task_sync_direct in get_outgoing_sync_tasks():
for provider in provider_model.objects.filter(provider_filter):
time_limit = int(
timedelta_from_string(provider.sync_page_timeout).total_seconds() * 1000
)
messages.append(
task_sync_direct.message_with_options(
args=(model, user.pk, provider.pk),
rel_obj=provider,
time_limit=time_limit,
uid=f"{provider.name}:user:{user.pk}:direct",
)
)
wait_timeout += time_limit
if not messages:
return
try:
group(messages).run().wait(timeout=wait_timeout)
except ResultTimeout:
self.logger.warning(
"Timed out waiting for outgoing sync tasks; tasks remain queued",
user=user.username,
timeout=wait_timeout,
)
def _get_lockdown_artifact_querysets(
self, stage: AccountLockdownStage, user: User
) -> tuple[QuerySet, ...]:
"""Return the configured sessions and tokens targeted by lockdown."""
querysets: list[QuerySet] = []
if stage.delete_sessions:
querysets.append(Session.objects.filter(authenticatedsession__user=user))
if stage.revoke_tokens:
querysets.extend(
get_lockdown_token_queryset(model, user) for model in get_lockdown_token_models()
)
return tuple(querysets)
def _delete_lockdown_artifacts(self, stage: AccountLockdownStage, user: User) -> None:
"""Delete sessions and tokens selected by the lockdown configuration."""
for queryset in self._get_lockdown_artifact_querysets(stage, user):
queryset.delete()
def _has_lockdown_artifacts(self, stage: AccountLockdownStage, user: User) -> bool:
"""Check whether there are still sessions or tokens to remove."""
return any(
queryset.exists() for queryset in self._get_lockdown_artifact_querysets(stage, user)
)
def _emit_lockdown_event(self, request: HttpRequest, user: User, reason: str) -> None:
"""Emit the audit event for a completed lockdown."""
# Emit the audit event after the transaction commits. If event creation
# fails here, dispatch() would otherwise treat the whole lockdown as
# failed even though the account changes have already been committed.
try:
Event.new(
EventAction.USER_WRITE,
action_id=LOCKDOWN_EVENT_ACTION_ID,
reason=reason,
affected_user=user.username,
).from_http(request)
except Exception as exc: # noqa: BLE001
# Event emission should not make the lockdown itself fail.
self.logger.warning(
"Failed to emit account lockdown event",
user=user.username,
exc=exc,
)
def _lockdown_user(
self,
request: HttpRequest,
stage: AccountLockdownStage,
user: User,
reason: str,
) -> None:
"""Execute lockdown actions on a single user."""
with atomic():
user = User.objects.get(pk=user.pk)
self._apply_lockdown_actions(stage, user)
self._delete_lockdown_artifacts(stage, user)
# These additional checks/deletes are done to prevent a timing attack that creates tokens
# with a compromised token that is simultaneously being deleted.
while self._has_lockdown_artifacts(stage, user):
with atomic():
self._delete_lockdown_artifacts(stage, user)
if stage.deactivate_user:
try:
self._sync_deactivated_user_to_outgoing_providers(user)
except Exception as exc: # noqa: BLE001
# Local lockdown has already committed. Provider sync failures
# must not reopen access or mark the lockdown itself as failed.
self.logger.warning(
"Failed to sync account lockdown deactivation to outgoing providers",
user=user.username,
exc=exc,
)
self._emit_lockdown_event(request, user, reason)
def dispatch(self, request: HttpRequest) -> HttpResponse:
"""Execute account lockdown actions."""
self.request = request
stage: AccountLockdownStage = self.executor.current_stage
pending_user = self.get_pending_user()
if not pending_user.is_authenticated:
self.logger.warning("No target user found for account lockdown")
return self.executor.stage_invalid(TARGET_REQUIRED_MESSAGE)
user = get_lockdown_target_users().filter(pk=pending_user.pk).first()
if user is None:
self.logger.warning("Target user is not eligible for account lockdown")
return self.executor.stage_invalid(TARGET_REQUIRED_MESSAGE)
if not can_lock_user(request.user, user):
self.logger.warning(
"Permission denied for account lockdown",
actor=getattr(request.user, "username", None),
target=user.username,
)
return self.executor.stage_invalid(PERMISSION_DENIED_MESSAGE)
reason = self.get_reason()
self_service = self.is_self_service(request, user)
if self_service and stage.delete_sessions and not stage.self_service_completion_flow:
self.logger.warning("No completion flow configured for self-service account lockdown")
return self.executor.stage_invalid(SELF_SERVICE_COMPLETION_FLOW_REQUIRED_MESSAGE)
self.logger.info(
"Executing account lockdown",
user=user.username,
reason=reason,
self_service=self_service,
deactivate_user=stage.deactivate_user,
set_unusable_password=stage.set_unusable_password,
delete_sessions=stage.delete_sessions,
revoke_tokens=stage.revoke_tokens,
)
try:
self._lockdown_user(request, stage, user, reason)
self.logger.info("Account lockdown completed", user=user.username)
except Exception as exc: # noqa: BLE001
# Convert unexpected lockdown errors to a flow-stage failure instead
# of leaking an exception through the flow executor.
self.logger.warning("Account lockdown failed", user=user.username, exc=exc)
return self.executor.stage_invalid(ACCOUNT_LOCKDOWN_FAILED_MESSAGE)
if self_service:
if stage.delete_sessions:
return self._self_service_completion_response(request)
return self.executor.stage_ok()
return self.executor.stage_ok()
def _self_service_completion_response(self, request: HttpRequest) -> HttpResponse:
"""Redirect to completion flow after self-service lockdown.
Since all sessions are deleted, the user cannot continue in the flow.
Redirect them to an unauthenticated completion flow that shows the
lockdown message.
We use a direct HTTP redirect instead of a challenge because the
flow executor's challenge handling may try to access the session
which we just deleted.
"""
stage: AccountLockdownStage = self.executor.current_stage
completion_flow = stage.self_service_completion_flow
if completion_flow:
# Flush the current request's session to prevent Django's session
# middleware from trying to save a deleted session
if hasattr(request, "session"):
request.session.flush()
redirect_to = reverse(
"authentik_core:if-flow",
kwargs={"flow_slug": completion_flow.slug},
)
return HttpResponseRedirect(redirect_to)
return self.executor.stage_invalid(SELF_SERVICE_COMPLETION_FLOW_REQUIRED_MESSAGE)

View File

@@ -0,0 +1,148 @@
"""Test Users Account Lockdown API"""
from json import loads
from unittest.mock import MagicMock, patch
from urllib.parse import urlparse
from django.urls import reverse
from rest_framework.test import APITestCase
from authentik.core.tests.utils import (
create_test_brand,
create_test_flow,
create_test_user,
)
from authentik.enterprise.stages.account_lockdown.models import AccountLockdownStage
from authentik.flows.models import FlowDesignation, FlowStageBinding
from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER
from authentik.flows.views.executor import SESSION_KEY_PLAN
from authentik.lib.generators import generate_id
# Patch for enterprise license check
patch_license = patch(
"authentik.enterprise.models.LicenseUsageStatus.is_valid",
MagicMock(return_value=True),
)
@patch_license
class AccountLockdownAPITestCase(APITestCase):
"""Shared helpers for account lockdown API tests."""
def setUp(self) -> None:
self.lockdown_flow = create_test_flow(FlowDesignation.STAGE_CONFIGURATION)
self.lockdown_stage = AccountLockdownStage.objects.create(name=generate_id())
FlowStageBinding.objects.create(
target=self.lockdown_flow,
stage=self.lockdown_stage,
order=0,
)
self.brand = create_test_brand()
self.brand.flow_lockdown = self.lockdown_flow
self.brand.save()
def create_user_with_email(self):
"""Create a regular user with a unique email address."""
user = create_test_user()
user.email = f"{generate_id()}@test.com"
user.save()
return user
def assert_redirect_targets(self, response, user):
"""Assert that a response contains a pre-planned lockdown flow link for a user."""
self.assertEqual(response.status_code, 200)
body = loads(response.content)
self.assertIn(self.lockdown_flow.slug, body["link"])
self.assertEqual(urlparse(body["link"]).query, "")
plan = self.client.session[SESSION_KEY_PLAN]
self.assertEqual(plan.context[PLAN_CONTEXT_PENDING_USER].pk, user.pk)
def assert_no_flow_configured(self, response):
"""Assert that the API reports a missing lockdown flow."""
self.assertEqual(response.status_code, 400)
body = loads(response.content)
self.assertIn("No lockdown flow configured", body["non_field_errors"][0])
@patch_license
class TestUsersAccountLockdownAPI(AccountLockdownAPITestCase):
"""Test Users Account Lockdown API"""
def setUp(self) -> None:
super().setUp()
self.actor = create_test_user()
self.user = self.create_user_with_email()
def test_account_lockdown_with_change_user_returns_redirect(self):
"""Test that account lockdown allows users with change_user permission."""
self.actor.assign_perms_to_managed_role("authentik_core.change_user", self.user)
self.client.force_login(self.actor)
response = self.client.post(
reverse("authentik_api:user-account-lockdown"),
data={"user": self.user.pk},
format="json",
)
self.assert_redirect_targets(response, self.user)
def test_account_lockdown_no_flow_configured(self):
"""Test account lockdown when no flow is configured"""
self.brand.flow_lockdown = None
self.brand.save()
self.actor.assign_perms_to_managed_role("authentik_core.change_user", self.user)
self.client.force_login(self.actor)
response = self.client.post(
reverse("authentik_api:user-account-lockdown"),
data={"user": self.user.pk},
format="json",
)
self.assert_no_flow_configured(response)
def test_account_lockdown_unauthenticated(self):
"""Test account lockdown requires authentication"""
response = self.client.post(
reverse("authentik_api:user-account-lockdown"),
data={"user": self.user.pk},
format="json",
)
self.assertEqual(response.status_code, 403)
def test_account_lockdown_without_change_user_denied(self):
"""Test account lockdown denies users without change_user permission."""
self.client.force_login(self.actor)
response = self.client.post(
reverse("authentik_api:user-account-lockdown"),
data={"user": self.user.pk},
format="json",
)
self.assertEqual(response.status_code, 403)
def test_account_lockdown_self_returns_redirect(self):
"""Test successful self-service account lockdown returns a direct redirect."""
self.client.force_login(self.user)
response = self.client.post(
reverse("authentik_api:user-account-lockdown"),
data={},
format="json",
)
self.assert_redirect_targets(response, self.user)
def test_account_lockdown_self_target_without_change_user_returns_redirect(self):
"""Test self-service does not require change_user permission."""
self.client.force_login(self.user)
response = self.client.post(
reverse("authentik_api:user-account-lockdown"),
data={"user": self.user.pk},
format="json",
)
self.assert_redirect_targets(response, self.user)

View File

@@ -0,0 +1,46 @@
"""Tests for the packaged account-lockdown blueprint."""
from unittest.mock import patch
from django.test import TransactionTestCase
from authentik.blueprints.models import BlueprintInstance
from authentik.blueprints.v1.importer import Importer
from authentik.blueprints.v1.tasks import blueprints_find, check_blueprint_v1_file
from authentik.enterprise.license import LicenseKey
from authentik.flows.models import Flow
BLUEPRINT_PATH = "example/flow-default-account-lockdown.yaml"
class TestAccountLockdownBlueprint(TransactionTestCase):
"""Test the packaged account-lockdown blueprint behavior."""
def test_blueprint_is_not_auto_instantiated(self):
"""Test the packaged blueprint is opt-in and skipped by discovery."""
BlueprintInstance.objects.filter(path=BLUEPRINT_PATH).delete()
blueprint = next(item for item in blueprints_find() if item.path == BLUEPRINT_PATH)
check_blueprint_v1_file(blueprint)
self.assertFalse(BlueprintInstance.objects.filter(path=BLUEPRINT_PATH).exists())
def test_blueprint_requires_licensed_context(self):
"""Test manual import only creates flows when enterprise is licensed."""
content = BlueprintInstance(path=BLUEPRINT_PATH).retrieve()
license_key = LicenseKey("test", 253402300799, "Test license", 1000, 1000)
with patch("authentik.enterprise.license.LicenseKey.get_total", return_value=license_key):
importer = Importer.from_string(content, {"goauthentik.io/enterprise/licensed": False})
valid, logs = importer.validate()
self.assertTrue(valid, logs)
self.assertTrue(importer.apply())
self.assertFalse(Flow.objects.filter(slug="default-account-lockdown").exists())
self.assertFalse(Flow.objects.filter(slug="default-account-lockdown-complete").exists())
importer = Importer.from_string(content, {"goauthentik.io/enterprise/licensed": True})
valid, logs = importer.validate()
self.assertTrue(valid, logs)
self.assertTrue(importer.apply())
self.assertTrue(Flow.objects.filter(slug="default-account-lockdown").exists())
self.assertTrue(Flow.objects.filter(slug="default-account-lockdown-complete").exists())

View File

@@ -0,0 +1,627 @@
"""Account lockdown stage tests"""
import json
from dataclasses import asdict
from threading import Event as ThreadEvent
from threading import Thread
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
from django.db import connection
from django.http import HttpResponse
from django.test import TransactionTestCase
from django.urls import reverse
from django.utils import timezone
from dramatiq.results.errors import ResultTimeout
from authentik.core.models import AuthenticatedSession, Session, Token, TokenIntents
from authentik.core.tests.utils import (
RequestFactory,
create_test_admin_user,
create_test_cert,
create_test_flow,
create_test_user,
)
from authentik.enterprise.stages.account_lockdown.models import AccountLockdownStage
from authentik.enterprise.stages.account_lockdown.stage import (
LOCKDOWN_EVENT_ACTION_ID,
PLAN_CONTEXT_LOCKDOWN_REASON,
AccountLockdownStageView,
can_lock_user,
)
from authentik.events.models import Event, EventAction
from authentik.flows.markers import StageMarker
from authentik.flows.models import FlowDesignation, FlowStageBinding
from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlan
from authentik.flows.tests import FlowTestCase
from authentik.lib.generators import generate_id
from authentik.lib.utils.reflection import class_to_path
from authentik.providers.oauth2.id_token import IDToken
from authentik.providers.oauth2.models import (
AccessToken,
AuthorizationCode,
DeviceToken,
OAuth2Provider,
RedirectURI,
RedirectURIMatchingMode,
RefreshToken,
)
from authentik.providers.saml.models import SAMLProvider, SAMLSession
from authentik.stages.prompt.stage import PLAN_CONTEXT_PROMPT
patch_enterprise_enabled = patch(
"authentik.enterprise.apps.AuthentikEnterpriseConfig.check_enabled",
return_value=True,
)
class AccountLockdownStageTestMixin:
"""Shared setup helpers for account lockdown stage tests."""
@classmethod
def setUpClass(cls):
cls.patch_enterprise_enabled = patch_enterprise_enabled.start()
cls.patch_event_dispatch = patch("authentik.events.tasks.event_trigger_dispatch.send")
cls.patch_event_dispatch.start()
super().setUpClass()
@classmethod
def tearDownClass(cls):
cls.patch_event_dispatch.stop()
patch_enterprise_enabled.stop()
super().tearDownClass()
def setUp(self):
super().setUp()
self.user = create_test_admin_user()
self.target_user = create_test_admin_user()
self.flow = create_test_flow(FlowDesignation.STAGE_CONFIGURATION)
self.stage = AccountLockdownStage.objects.create(
name="lockdown",
)
self.binding = FlowStageBinding.objects.create(target=self.flow, stage=self.stage, order=0)
self.request_factory = RequestFactory()
def make_stage_view(self, plan: FlowPlan):
def _stage_ok():
return HttpResponse(status=204)
def _stage_invalid(_error_message=None):
return HttpResponse(status=400)
return AccountLockdownStageView(
SimpleNamespace(
plan=plan,
current_stage=self.stage,
current_binding=self.binding,
flow=self.flow,
stage_ok=_stage_ok,
stage_invalid=_stage_invalid,
)
)
def make_request(self, *, user=None, query=None):
return self.request_factory.post(
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
query_params=query or {},
user=user,
)
def get_lockdown_event(self):
"""Return the account-lockdown user-write event."""
return Event.objects.filter(
action=EventAction.USER_WRITE,
context__action_id=LOCKDOWN_EVENT_ACTION_ID,
).first()
class TestAccountLockdownStage(AccountLockdownStageTestMixin, FlowTestCase):
"""Account lockdown stage tests"""
def test_lockdown_no_target(self):
"""Test lockdown stage with no pending user fails"""
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
view = self.make_stage_view(plan)
response = view.dispatch(self.make_request())
self.assertEqual(response.status_code, 400)
def test_lockdown_with_pending_user(self):
"""Test lockdown stage with a pending target user."""
self.target_user.is_active = True
self.target_user.save()
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
plan.context[PLAN_CONTEXT_LOCKDOWN_REASON] = "Security incident"
plan.context[PLAN_CONTEXT_PENDING_USER] = self.target_user
view = self.make_stage_view(plan)
request = self.make_request(user=self.user)
self.assertTrue(can_lock_user(request.user, self.target_user))
response = view.dispatch(request)
self.target_user.refresh_from_db()
self.assertFalse(self.target_user.is_active)
self.assertFalse(self.target_user.has_usable_password())
self.assertEqual(response.status_code, 204)
# Check event was created
event = self.get_lockdown_event()
self.assertIsNotNone(event)
self.assertEqual(event.context["action_id"], LOCKDOWN_EVENT_ACTION_ID)
self.assertEqual(event.context["reason"], "Security incident")
self.assertEqual(event.context["affected_user"], self.target_user.username)
def test_lockdown_with_pending_user_reason(self):
"""Test lockdown stage with a pending target and explicit reason."""
self.target_user.is_active = True
self.target_user.save()
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
plan.context[PLAN_CONTEXT_LOCKDOWN_REASON] = "Compromised account"
plan.context[PLAN_CONTEXT_PENDING_USER] = self.target_user
view = self.make_stage_view(plan)
request = self.make_request(user=self.user)
self.assertTrue(can_lock_user(request.user, self.target_user))
response = view.dispatch(request)
self.target_user.refresh_from_db()
self.assertFalse(self.target_user.is_active)
self.assertEqual(response.status_code, 204)
def test_lockdown_reason_from_prompt(self):
"""Test lockdown stage reads the reason from prompt data."""
self.target_user.is_active = True
self.target_user.save()
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
plan.context[PLAN_CONTEXT_PROMPT] = {
PLAN_CONTEXT_LOCKDOWN_REASON: "User requested lockdown",
}
view = self.make_stage_view(plan)
request = self.make_request(user=self.user)
view._lockdown_user(request, self.stage, self.target_user, view.get_reason())
event = self.get_lockdown_event()
self.assertIsNotNone(event)
self.assertEqual(event.context["reason"], "User requested lockdown")
def test_lockdown_event_failure_does_not_fail_self_service(self):
"""Test lockdown still succeeds when event emission fails."""
self.stage.delete_sessions = False
self.stage.save()
self.target_user.is_active = True
self.target_user.save()
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
plan.context[PLAN_CONTEXT_PENDING_USER] = self.target_user
view = self.make_stage_view(plan)
request = self.make_request(user=self.target_user)
original_event_new = Event.new
def _event_new_side_effect(action, *args, **kwargs):
if (
action == EventAction.USER_WRITE
and kwargs.get("action_id") == LOCKDOWN_EVENT_ACTION_ID
):
raise RuntimeError("simulated event failure")
return original_event_new(action, *args, **kwargs)
with patch(
"authentik.enterprise.stages.account_lockdown.stage.Event.new",
side_effect=_event_new_side_effect,
):
view._lockdown_user(request, self.stage, self.target_user, view.get_reason())
self.target_user.refresh_from_db()
self.assertFalse(self.target_user.is_active)
def test_dispatch_records_success_when_event_emission_fails(self):
"""Test dispatch still completes if event emission fails."""
self.stage.delete_sessions = False
self.stage.save()
self.target_user.is_active = True
self.target_user.save()
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
plan.context[PLAN_CONTEXT_PENDING_USER] = self.target_user
view = self.make_stage_view(plan)
request = self.make_request(
user=self.target_user,
)
original_event_new = Event.new
def _event_new_side_effect(action, *args, **kwargs):
if (
action == EventAction.USER_WRITE
and kwargs.get("action_id") == LOCKDOWN_EVENT_ACTION_ID
):
raise RuntimeError("simulated event failure")
return original_event_new(action, *args, **kwargs)
with patch(
"authentik.enterprise.stages.account_lockdown.stage.Event.new",
side_effect=_event_new_side_effect,
):
response = view.dispatch(request)
self.target_user.refresh_from_db()
self.assertFalse(self.target_user.is_active)
self.assertEqual(response.status_code, 204)
def test_lockdown_self_service_redirects_to_completion_flow(self):
"""Test self-service lockdown redirects to completion flow when sessions are deleted."""
completion_flow = create_test_flow(FlowDesignation.STAGE_CONFIGURATION)
self.stage.self_service_completion_flow = completion_flow
self.stage.save()
self.target_user.is_active = True
self.target_user.save()
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
view = self.make_stage_view(plan)
request = self.make_request(user=self.target_user)
view._lockdown_user(request, self.stage, self.target_user, view.get_reason())
response = view._self_service_completion_response(request)
self.assertEqual(response.status_code, 302)
self.assertEqual(
response.url,
reverse("authentik_core:if-flow", kwargs={"flow_slug": completion_flow.slug}),
)
def test_lockdown_self_service_requires_completion_flow(self):
"""Test self-service lockdown fails before deleting sessions without a completion flow."""
self.stage.self_service_completion_flow = None
self.stage.save()
self.target_user.is_active = True
self.target_user.save()
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
plan.context[PLAN_CONTEXT_PENDING_USER] = self.target_user
view = self.make_stage_view(plan)
request = self.make_request(user=self.target_user)
response = view.dispatch(request)
self.assertEqual(response.status_code, 400)
self.target_user.refresh_from_db()
self.assertTrue(self.target_user.is_active)
def test_lockdown_denies_other_user_without_permission(self):
"""Test lockdown stage rejects non-self requests without change_user permission."""
actor = create_test_user()
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
plan.context[PLAN_CONTEXT_PENDING_USER] = self.target_user
view = self.make_stage_view(plan)
request = self.make_request(user=actor)
self.assertFalse(can_lock_user(request.user, self.target_user))
response = view.dispatch(request)
self.assertEqual(response.status_code, 400)
def test_lockdown_revokes_tokens(self):
"""Test lockdown stage revokes tokens"""
Token.objects.create(
user=self.target_user,
identifier="test-token",
intent=TokenIntents.INTENT_API,
key=generate_id(),
expiring=False,
)
self.assertEqual(Token.objects.filter(user=self.target_user).count(), 1)
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
view = self.make_stage_view(plan)
view._lockdown_user(self.make_request(user=self.user), self.stage, self.target_user, "")
self.assertEqual(Token.objects.filter(user=self.target_user).count(), 0)
def test_lockdown_revokes_provider_tokens(self):
"""Test lockdown stage revokes provider tokens and sessions."""
oauth_provider = OAuth2Provider.objects.create(
name=generate_id(),
authorization_flow=create_test_flow(),
redirect_uris=[
RedirectURI(RedirectURIMatchingMode.STRICT, "http://testserver/callback")
],
signing_key=create_test_cert(),
)
saml_provider = SAMLProvider.objects.create(
name=generate_id(),
authorization_flow=create_test_flow(),
acs_url="https://sp.example.com/acs",
issuer_override="https://idp.example.com",
)
session = Session.objects.create(
session_key=generate_id(),
expires=timezone.now() + timezone.timedelta(hours=1),
last_ip="127.0.0.1",
)
auth_session = AuthenticatedSession.objects.create(
session=session,
user=self.target_user,
)
grant_kwargs = {
"provider": oauth_provider,
"user": self.target_user,
"auth_time": timezone.now(),
"_scope": "openid profile",
"expiring": False,
}
token_kwargs = grant_kwargs | {"_id_token": json.dumps(asdict(IDToken("foo", "bar")))}
AuthorizationCode.objects.create(
code=generate_id(),
session=auth_session,
**grant_kwargs,
)
AccessToken.objects.create(
token=generate_id(),
session=auth_session,
**token_kwargs,
)
RefreshToken.objects.create(
token=generate_id(),
session=auth_session,
**token_kwargs,
)
DeviceToken.objects.create(
provider=oauth_provider,
user=self.target_user,
session=auth_session,
_scope="openid profile",
expiring=False,
)
SAMLSession.objects.create(
provider=saml_provider,
user=self.target_user,
session=auth_session,
session_index=generate_id(),
name_id=self.target_user.email,
expires=timezone.now() + timezone.timedelta(hours=1),
expiring=True,
)
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
view = self.make_stage_view(plan)
view._lockdown_user(self.make_request(user=self.user), self.stage, self.target_user, "")
self.assertEqual(AuthorizationCode.objects.filter(user=self.target_user).count(), 0)
self.assertEqual(AccessToken.objects.filter(user=self.target_user).count(), 0)
self.assertEqual(RefreshToken.objects.filter(user=self.target_user).count(), 0)
self.assertEqual(DeviceToken.objects.filter(user=self.target_user).count(), 0)
self.assertEqual(SAMLSession.objects.filter(user=self.target_user).count(), 0)
def test_lockdown_selective_actions(self):
"""Test lockdown stage with selective actions"""
self.stage.deactivate_user = True
self.stage.set_unusable_password = False
self.stage.delete_sessions = False
self.stage.revoke_tokens = False
self.stage.save()
self.target_user.is_active = True
self.target_user.set_password("testpassword")
self.target_user.save()
Token.objects.create(
user=self.target_user,
identifier="test-token",
intent=TokenIntents.INTENT_API,
key=generate_id(),
expiring=False,
)
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
view = self.make_stage_view(plan)
view._lockdown_user(self.make_request(user=self.user), self.stage, self.target_user, "")
self.target_user.refresh_from_db()
# User should be deactivated
self.assertFalse(self.target_user.is_active)
# Password should still be usable
self.assertTrue(self.target_user.has_usable_password())
# Token should still exist
self.assertEqual(Token.objects.filter(user=self.target_user).count(), 1)
def test_lockdown_no_actions(self):
"""Test lockdown stage with all actions disabled"""
self.stage.deactivate_user = False
self.stage.set_unusable_password = False
self.stage.delete_sessions = False
self.stage.revoke_tokens = False
self.stage.save()
self.target_user.is_active = True
self.target_user.set_password("testpassword")
self.target_user.save()
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
view = self.make_stage_view(plan)
view._lockdown_user(self.make_request(user=self.user), self.stage, self.target_user, "")
self.target_user.refresh_from_db()
# User should still be active
self.assertTrue(self.target_user.is_active)
# Password should still be usable
self.assertTrue(self.target_user.has_usable_password())
# Event should still be created
event = self.get_lockdown_event()
self.assertIsNotNone(event)
def test_lockdown_deactivation_inhibits_signal_dispatch_until_after_commit(self):
"""Test lockdown queues explicit outgoing syncs after the deactivation transaction."""
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
view = self.make_stage_view(plan)
with (
patch(
"authentik.enterprise.stages.account_lockdown.stage.sync_outgoing_inhibit_dispatch"
) as inhibit,
patch.object(view, "_sync_deactivated_user_to_outgoing_providers") as sync_outgoing,
):
view._lockdown_user(self.make_request(user=self.user), self.stage, self.target_user, "")
inhibit.assert_called_once()
sync_outgoing.assert_called_once()
synced_user = sync_outgoing.call_args.args[0]
self.assertEqual(synced_user.pk, self.target_user.pk)
self.assertFalse(synced_user.is_active)
def test_lockdown_waits_for_direct_outgoing_provider_syncs(self):
"""Test direct outgoing sync tasks are enqueued and waited on."""
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
view = self.make_stage_view(plan)
provider = SimpleNamespace(name="outgoing", pk=1, sync_page_timeout="seconds=5")
task_sync_direct = MagicMock()
task_sync_direct.message_with_options.return_value = "direct-message"
provider_model = SimpleNamespace(
objects=SimpleNamespace(filter=MagicMock(return_value=[provider]))
)
task_group = MagicMock()
with (
patch(
"authentik.enterprise.stages.account_lockdown.stage.get_outgoing_sync_tasks",
return_value=((provider_model, task_sync_direct),),
),
patch(
"authentik.enterprise.stages.account_lockdown.stage.group",
return_value=task_group,
) as task_group_cls,
):
view._sync_deactivated_user_to_outgoing_providers(self.target_user)
task_sync_direct.message_with_options.assert_called_once_with(
args=(class_to_path(type(self.target_user)), self.target_user.pk, provider.pk),
rel_obj=provider,
time_limit=5000,
uid=f"{provider.name}:user:{self.target_user.pk}:direct",
)
task_group_cls.assert_called_once_with(["direct-message"])
task_group.run.return_value.wait.assert_called_once_with(timeout=5000)
def test_lockdown_outgoing_provider_sync_timeout_leaves_tasks_running(self):
"""Test timeout while waiting for direct outgoing syncs does not fail lockdown."""
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
view = self.make_stage_view(plan)
provider = SimpleNamespace(name="outgoing", pk=1, sync_page_timeout="seconds=5")
task_sync_direct = MagicMock()
task_sync_direct.message_with_options.return_value = "direct-message"
provider_model = SimpleNamespace(
objects=SimpleNamespace(filter=MagicMock(return_value=[provider]))
)
task_group = MagicMock()
task_group.run.return_value.wait.side_effect = ResultTimeout("timed out")
with (
patch(
"authentik.enterprise.stages.account_lockdown.stage.get_outgoing_sync_tasks",
return_value=((provider_model, task_sync_direct),),
),
patch(
"authentik.enterprise.stages.account_lockdown.stage.group",
return_value=task_group,
),
):
view._sync_deactivated_user_to_outgoing_providers(self.target_user)
task_group.run.assert_called_once_with()
task_group.run.return_value.wait.assert_called_once_with(timeout=5000)
def test_lockdown_outgoing_provider_sync_failure_does_not_fail_lockdown(self):
"""Test completed local lockdown still emits an event if outgoing sync fails."""
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
view = self.make_stage_view(plan)
with patch.object(
view,
"_sync_deactivated_user_to_outgoing_providers",
side_effect=ValueError("sync failed"),
):
view._lockdown_user(self.make_request(user=self.user), self.stage, self.target_user, "")
self.target_user.refresh_from_db()
self.assertFalse(self.target_user.is_active)
event = self.get_lockdown_event()
self.assertIsNotNone(event)
class TestAccountLockdownStageConcurrency(AccountLockdownStageTestMixin, TransactionTestCase):
"""Account lockdown concurrency tests."""
def test_lockdown_retries_when_another_transaction_recreates_a_token(self):
"""Lockdown should remove a token recreated before the retry check runs."""
Token.objects.create(
user=self.target_user,
identifier=f"initial-token-{generate_id()}",
intent=TokenIntents.INTENT_API,
key=generate_id(),
expiring=False,
)
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
view = self.make_stage_view(plan)
original_has_artifacts = view._has_lockdown_artifacts
target_user = self.target_user
thread_ready = ThreadEvent()
start_create = ThreadEvent()
thread_done = ThreadEvent()
thread_errors = []
class TokenCreatorThread(Thread):
__test__ = False
def run(self):
try:
thread_ready.set()
if not start_create.wait(timeout=5):
thread_errors.append("timed out waiting to recreate token")
return
Token.objects.create(
user=target_user,
identifier=f"concurrent-token-{generate_id()}",
intent=TokenIntents.INTENT_API,
key=generate_id(),
expiring=False,
)
except Exception as exc: # noqa: BLE001
thread_errors.append(exc)
finally:
thread_done.set()
connection.close()
def has_artifacts_after_concurrent_create(stage, user):
if not start_create.is_set():
start_create.set()
self.assertTrue(
thread_done.wait(timeout=30),
(
"Concurrent token creation did not complete "
f"before retry check: {thread_errors}"
),
)
return original_has_artifacts(stage, user)
creator = TokenCreatorThread()
with patch.object(
view, "_has_lockdown_artifacts", side_effect=has_artifacts_after_concurrent_create
):
creator.start()
self.assertTrue(
thread_ready.wait(timeout=5),
"Concurrent token creation thread did not start",
)
view._lockdown_user(self.make_request(user=self.user), self.stage, self.target_user, "")
creator.join()
self.assertEqual(thread_errors, [])
self.assertEqual(Token.objects.filter(user=self.target_user).count(), 0)

View File

@@ -0,0 +1,5 @@
"""API URLs"""
from authentik.enterprise.stages.account_lockdown.api import AccountLockdownStageViewSet
api_urlpatterns = [("stages/account_lockdown", AccountLockdownStageViewSet)]

View File

@@ -172,6 +172,7 @@ SPECTACULAR_SETTINGS = {
},
"ENUM_NAME_OVERRIDES": {
"AppEnum": "authentik.lib.api.Apps",
"AuthenticationEnum": "authentik.flows.models.FlowAuthenticationRequirement",
"ConsentModeEnum": "authentik.stages.consent.models.ConsentMode",
"CountryCodeEnum": "django_countries.countries",
"DeviceClassesEnum": "authentik.stages.authenticator_validate.models.DeviceClasses",

View File

@@ -0,0 +1,64 @@
# Generated by Django 5.2.12 on 2026-03-14 02:58
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
(
"authentik_stages_prompt",
"0011_prompt_initial_value_prompt_initial_value_expression_and_more",
),
]
operations = [
migrations.AlterField(
model_name="prompt",
name="type",
field=models.CharField(
choices=[
("text", "Text: Simple Text input"),
("text_area", "Text area: Multiline Text Input."),
(
"text_read_only",
"Text (read-only): Simple Text input, but cannot be edited.",
),
(
"text_area_read_only",
"Text area (read-only): Multiline Text input, but cannot be edited.",
),
(
"username",
"Username: Same as Text input, but checks for and prevents duplicate usernames.",
),
("email", "Email: Text field with Email type."),
(
"password",
"Password: Masked input, multiple inputs of this type on the same prompt need to be identical.",
),
("number", "Number"),
("checkbox", "Checkbox"),
(
"radio-button-group",
"Fixed choice field rendered as a group of radio buttons.",
),
("dropdown", "Fixed choice field rendered as a dropdown."),
("date", "Date"),
("date-time", "Date Time"),
(
"file",
"File: File upload for arbitrary files. File content will be available in flow context as data-URI",
),
("separator", "Separator: Static Separator Line"),
("hidden", "Hidden: Hidden field, can be used to insert data into form."),
("static", "Static: Static value, displayed as-is."),
("alert_info", "Alert (Info): Static alert box with info styling"),
("alert_warning", "Alert (Warning): Static alert box with warning styling"),
("alert_danger", "Alert (Danger): Static alert box with danger styling"),
("ak-locale", "authentik: Selection of locales authentik supports"),
],
max_length=100,
),
),
]

View File

@@ -87,6 +87,11 @@ class FieldTypes(models.TextChoices):
HIDDEN = "hidden", _("Hidden: Hidden field, can be used to insert data into form.")
STATIC = "static", _("Static: Static value, displayed as-is.")
# Alert box types for displaying styled messages
ALERT_INFO = "alert_info", _("Alert (Info): Static alert box with info styling")
ALERT_WARNING = "alert_warning", _("Alert (Warning): Static alert box with warning styling")
ALERT_DANGER = "alert_danger", _("Alert (Danger): Static alert box with danger styling")
AK_LOCALE = "ak-locale", _("authentik: Selection of locales authentik supports")
@@ -299,7 +304,12 @@ class Prompt(SerializerModel):
field_class = HiddenField
kwargs["required"] = False
kwargs["default"] = self.placeholder
case FieldTypes.STATIC:
case (
FieldTypes.STATIC
| FieldTypes.ALERT_INFO
| FieldTypes.ALERT_WARNING
| FieldTypes.ALERT_DANGER
):
kwargs["default"] = self.placeholder
kwargs["required"] = False
kwargs["label"] = ""

View File

@@ -124,6 +124,9 @@ class PromptChallengeResponse(ChallengeResponse):
type__in=[
FieldTypes.HIDDEN,
FieldTypes.STATIC,
FieldTypes.ALERT_INFO,
FieldTypes.ALERT_WARNING,
FieldTypes.ALERT_DANGER,
FieldTypes.TEXT_READ_ONLY,
FieldTypes.TEXT_AREA_READ_ONLY,
]

View File

@@ -330,10 +330,20 @@ class TestPromptStage(FlowTestCase):
def test_static_hidden_overwrite(self):
"""Test that static and hidden fields ignore any value sent to them"""
alert_prompt = Prompt.objects.create(
name=generate_id(),
field_key="alert_prompt",
type=FieldTypes.ALERT_INFO,
required=True,
placeholder="alert fallback",
initial_value="alert content",
)
self.stage.fields.add(alert_prompt)
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
plan.context[PLAN_CONTEXT_PROMPT] = {"hidden_prompt": "hidden"}
self.prompt_data["hidden_prompt"] = "foo"
self.prompt_data["static_prompt"] = "foo"
self.prompt_data["alert_prompt"] = "foo"
challenge_response = PromptChallengeResponse(
None, stage_instance=self.stage, plan=plan, data=self.prompt_data, stage=self.stage_view
)
@@ -341,6 +351,7 @@ class TestPromptStage(FlowTestCase):
self.assertNotEqual(challenge_response.validated_data["hidden_prompt"], "foo")
self.assertEqual(challenge_response.validated_data["hidden_prompt"], "hidden")
self.assertNotEqual(challenge_response.validated_data["static_prompt"], "foo")
self.assertEqual(challenge_response.validated_data["alert_prompt"], "alert content")
def test_prompt_placeholder(self):
"""Test placeholder and expression"""

View File

@@ -0,0 +1,306 @@
version: 1
metadata:
name: Example - Account lockdown flow
labels:
blueprints.goauthentik.io/instantiate: "false"
entries:
flows:
# Main lockdown flow - requires authentication
- conditions:
- !Context goauthentik.io/enterprise/licensed
attrs:
designation: stage_configuration
name: Account Lockdown
title: Lock Account
authentication: require_authenticated
identifiers:
slug: default-account-lockdown
model: authentik_flows.flow
id: flow
# Self-service completion flow - no authentication required
- conditions:
- !Context goauthentik.io/enterprise/licensed
attrs:
designation: stage_configuration
name: Account Lockdown Complete
title: Account Locked
authentication: none
identifiers:
slug: default-account-lockdown-complete
model: authentik_flows.flow
id: completion-flow
prompt_fields:
# Warning field - danger alert box (content varies based on self-service vs admin)
- conditions:
- !Context goauthentik.io/enterprise/licensed
attrs:
order: 50
initial_value: |
target_uuid = (http_request.session.get("authentik/flows/get", {}) or {}).get("user_uuid")
current_user_uuid = str(getattr(user, "pk", "") or getattr(http_request.user, "pk", ""))
is_self_service = not target_uuid or target_uuid == current_user_uuid
pending_user = None
if target_uuid and not is_self_service:
from authentik.core.models import User
pending_user = User.objects.filter(pk=target_uuid).first()
if is_self_service:
return (
"<p><strong>You are about to lock down your own account.</strong></p>"
"<p>This is an emergency action for cutting off access to your account right away.</p>"
"<p><strong>This will immediately:</strong></p>"
"<ul>"
"<li><strong>Invalidate your password</strong> - Your password will be set to a random value "
"and cannot be recovered</li>"
"<li><strong>Deactivate your account</strong> - Your account will be disabled</li>"
"<li><strong>Terminate all your sessions</strong> - You will be logged out everywhere</li>"
"<li><strong>Revoke all your tokens</strong> - All your API, app password, recovery, "
"verification, and OAuth2 tokens and grants will be revoked</li>"
"</ul>"
"<p><strong>This action cannot be easily undone.</strong></p>"
)
from django.utils.html import escape
if pending_user:
email = escape(pending_user.email or pending_user.name or "No email")
user_html = f"<p><code>{escape(pending_user.username)}</code> ({email})</p>"
else:
user_html = "<p>the account selected when this one-time lockdown link was created</p>"
return (
"<p><strong>You are about to lock down the following account:</strong></p>"
f"{user_html}"
"<p>This is an emergency action for cutting off access to the account right away. "
"It does not lock the administrator who opened this page.</p>"
"<p><strong>This will immediately:</strong></p>"
"<ul>"
"<li>Invalidate the user's password</li>"
"<li>Deactivate the user</li>"
"<li>Terminate all sessions - All active sessions will be ended</li>"
"<li>Revoke all tokens - All API, app password, recovery, verification, and OAuth2 "
"tokens and grants will be revoked</li>"
"</ul>"
"<p><strong>This action cannot be easily undone.</strong></p>"
)
initial_value_expression: true
required: false
type: alert_danger
field_key: lockdown_warning
label: Warning
sub_text: ""
identifiers:
name: default-account-lockdown-field-warning
id: prompt-field-warning
model: authentik_stages_prompt.prompt
# Info field - when to use lockdown (content varies based on self-service vs admin)
- conditions:
- !Context goauthentik.io/enterprise/licensed
attrs:
order: 100
initial_value: |
target_uuid = (http_request.session.get("authentik/flows/get", {}) or {}).get("user_uuid")
current_user_uuid = str(getattr(user, "pk", "") or getattr(http_request.user, "pk", ""))
is_self_service = not target_uuid or target_uuid == current_user_uuid
if is_self_service:
info = (
"Use this if you no longer trust your current password or sessions. "
"After lockdown, you will need help from your administrator or security team to regain access."
)
else:
info = (
"Use this for incident response on the listed account, for example after a compromise report "
"or suspicious activity. The reason you enter below will be recorded in the audit log."
)
return (
f"<p>{info}</p>"
'<p><a href="https://docs.goauthentik.io/docs/security/'
'account-lockdown?utm_source=authentik" '
'target="_blank" rel="noopener noreferrer">Learn more about account lockdown</a></p>'
)
initial_value_expression: true
required: false
type: alert_info
field_key: lockdown_info
label: Information
sub_text: ""
identifiers:
name: default-account-lockdown-field-info
id: prompt-field-info
model: authentik_stages_prompt.prompt
# Reason field - text area for lockdown reason
- conditions:
- !Context goauthentik.io/enterprise/licensed
attrs:
order: 200
placeholder: |
target_uuid = (http_request.session.get("authentik/flows/get", {}) or {}).get("user_uuid")
current_user_uuid = str(getattr(user, "pk", "") or getattr(http_request.user, "pk", ""))
is_self_service = not target_uuid or target_uuid == current_user_uuid
if is_self_service:
return "Describe why you are locking your account..."
return "Describe why this account is being locked down..."
placeholder_expression: true
required: true
type: text_area
field_key: lockdown_reason
label: Reason
sub_text: This explanation will be recorded in the audit log.
identifiers:
name: default-account-lockdown-field-reason
id: prompt-field-reason
model: authentik_stages_prompt.prompt
prompt_stages:
# Prompt stage for warnings and reason input
- conditions:
- !Context goauthentik.io/enterprise/licensed
attrs:
fields:
- !KeyOf prompt-field-warning
- !KeyOf prompt-field-info
- !KeyOf prompt-field-reason
identifiers:
name: default-account-lockdown-prompt
id: default-account-lockdown-prompt
model: authentik_stages_prompt.promptstage
lockdown_stage:
# Account lockdown stage - performs the actual lockdown
- conditions:
- !Context goauthentik.io/enterprise/licensed
identifiers:
name: default-account-lockdown-stage
id: default-account-lockdown-stage
model: authentik_stages_account_lockdown.accountlockdownstage
attrs:
deactivate_user: true
set_unusable_password: true
delete_sessions: true
revoke_tokens: true
self_service_completion_flow: !Find [authentik_flows.flow, [slug, default-account-lockdown-complete]]
completion_prompt:
# Completion message field - confirmation shown after an admin-triggered lockdown
- conditions:
- !Context goauthentik.io/enterprise/licensed
attrs:
order: 300
initial_value: |
target_uuid = (http_request.session.get("authentik/flows/get", {}) or {}).get("user_uuid")
from django.utils.html import escape
from authentik.core.models import User
if target_uuid:
target = User.objects.filter(pk=target_uuid).first()
if target:
return f"<p><code>{escape(target.username)}</code> has been locked down.</p>"
return "<p>The selected account has been locked down.</p>"
initial_value_expression: true
required: false
type: alert_info
field_key: lockdown_complete
label: Result
sub_text: ""
identifiers:
name: default-account-lockdown-field-complete
id: prompt-field-complete
model: authentik_stages_prompt.prompt
# Prompt stage for admin completion message
- conditions:
- !Context goauthentik.io/enterprise/licensed
attrs:
fields:
- !KeyOf prompt-field-complete
identifiers:
name: default-account-lockdown-complete-prompt
id: default-account-lockdown-complete-prompt
model: authentik_stages_prompt.promptstage
policies:
# Expression policy to check if this is NOT a self-service lockdown (admin)
- conditions:
- !Context goauthentik.io/enterprise/licensed
attrs:
name: default-account-lockdown-admin-policy
expression: |
target_uuid = (request.http_request.session.get("authentik/flows/get", {}) or {}).get("user_uuid")
current_user_uuid = str(getattr(request.user, "pk", "") or getattr(request.http_request.user, "pk", ""))
return bool(target_uuid) and target_uuid != current_user_uuid
identifiers:
name: default-account-lockdown-admin-policy
id: admin-policy
model: authentik_policies_expression.expressionpolicy
bindings:
# Stage bindings
- conditions:
- !Context goauthentik.io/enterprise/licensed
identifiers:
order: 0
stage: !KeyOf default-account-lockdown-prompt
target: !KeyOf flow
model: authentik_flows.flowstagebinding
- conditions:
- !Context goauthentik.io/enterprise/licensed
identifiers:
order: 10
stage: !KeyOf default-account-lockdown-stage
target: !KeyOf flow
model: authentik_flows.flowstagebinding
# Admin completion stage binding - shown for admin lockdown only
- conditions:
- !Context goauthentik.io/enterprise/licensed
identifiers:
order: 20
stage: !KeyOf default-account-lockdown-complete-prompt
target: !KeyOf flow
id: admin-completion-binding
model: authentik_flows.flowstagebinding
# Bind the admin policy to the admin completion stage
- conditions:
- !Context goauthentik.io/enterprise/licensed
attrs:
enabled: true
negate: false
order: 0
identifiers:
policy: !KeyOf admin-policy
target: !KeyOf admin-completion-binding
model: authentik_policies.policybinding
self_service_completion:
# Self-service completion message field (for the unauthenticated completion flow)
- conditions:
- !Context goauthentik.io/enterprise/licensed
attrs:
order: 100
initial_value: |
return (
"<h1>Your account has been locked</h1>"
"<p>You have been logged out of all sessions and your password has been invalidated.</p>"
"<p>To regain access to your account, please contact your IT administrator or security team.</p>"
)
initial_value_expression: true
required: false
type: alert_warning
field_key: self_lockdown_complete
label: Account locked
sub_text: ""
identifiers:
name: default-account-lockdown-self-field-complete
id: self-prompt-field-complete
model: authentik_stages_prompt.prompt
# Prompt stage for self-service completion (unauthenticated)
- conditions:
- !Context goauthentik.io/enterprise/licensed
attrs:
fields:
- !KeyOf self-prompt-field-complete
identifiers:
name: default-account-lockdown-self-complete-prompt
id: default-account-lockdown-self-complete-prompt
model: authentik_stages_prompt.promptstage
# Bind self-service completion stage to the completion flow
- conditions:
- !Context goauthentik.io/enterprise/licensed
identifiers:
order: 0
stage: !KeyOf default-account-lockdown-self-complete-prompt
target: !Find [authentik_flows.flow, [slug, default-account-lockdown-complete]]
model: authentik_flows.flowstagebinding

View File

@@ -1216,6 +1216,46 @@
}
}
},
{
"type": "object",
"required": [
"model",
"identifiers"
],
"properties": {
"model": {
"const": "authentik_stages_account_lockdown.accountlockdownstage"
},
"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_stages_account_lockdown.accountlockdownstage_permissions"
},
"attrs": {
"$ref": "#/$defs/model_authentik_stages_account_lockdown.accountlockdownstage"
},
"identifiers": {
"$ref": "#/$defs/model_authentik_stages_account_lockdown.accountlockdownstage"
}
}
},
{
"type": "object",
"required": [
@@ -5100,6 +5140,11 @@
"format": "uuid",
"title": "Flow device code"
},
"flow_lockdown": {
"type": "string",
"format": "uuid",
"title": "Flow lockdown"
},
"default_application": {
"type": "string",
"format": "uuid",
@@ -6094,6 +6139,10 @@
"authentik_sources_telegram.view_telegramsource",
"authentik_sources_telegram.view_telegramsourcepropertymapping",
"authentik_sources_telegram.view_usertelegramsourceconnection",
"authentik_stages_account_lockdown.add_accountlockdownstage",
"authentik_stages_account_lockdown.change_accountlockdownstage",
"authentik_stages_account_lockdown.delete_accountlockdownstage",
"authentik_stages_account_lockdown.view_accountlockdownstage",
"authentik_stages_authenticator_duo.add_authenticatorduostage",
"authentik_stages_authenticator_duo.add_duodevice",
"authentik_stages_authenticator_duo.change_authenticatorduostage",
@@ -7757,6 +7806,69 @@
}
}
},
"model_authentik_stages_account_lockdown.accountlockdownstage": {
"type": "object",
"properties": {
"name": {
"type": "string",
"minLength": 1,
"title": "Name"
},
"deactivate_user": {
"type": "boolean",
"title": "Deactivate user",
"description": "Deactivate the user account (set is_active to False)"
},
"set_unusable_password": {
"type": "boolean",
"title": "Set unusable password",
"description": "Set an unusable password for the user"
},
"delete_sessions": {
"type": "boolean",
"title": "Delete sessions",
"description": "Delete all active sessions for the user"
},
"revoke_tokens": {
"type": "boolean",
"title": "Revoke tokens",
"description": "Revoke all tokens for the user (API, app password, recovery, verification, OAuth)"
},
"self_service_completion_flow": {
"type": "string",
"format": "uuid",
"title": "Self service completion flow",
"description": "Flow to redirect users to after self-service lockdown. This flow should not require authentication since the user's session is deleted."
}
},
"required": []
},
"model_authentik_stages_account_lockdown.accountlockdownstage_permissions": {
"type": "array",
"items": {
"type": "object",
"required": [
"permission"
],
"properties": {
"permission": {
"type": "string",
"enum": [
"add_accountlockdownstage",
"change_accountlockdownstage",
"delete_accountlockdownstage",
"view_accountlockdownstage"
]
},
"user": {
"type": "integer"
},
"role": {
"type": "string"
}
}
}
},
"model_authentik_stages_authenticator_endpoint_gdtc.authenticatorendpointgdtcstage": {
"type": "object",
"properties": {
@@ -8952,6 +9064,7 @@
"authentik.enterprise.providers.ssf",
"authentik.enterprise.providers.ws_federation",
"authentik.enterprise.reports",
"authentik.enterprise.stages.account_lockdown",
"authentik.enterprise.stages.authenticator_endpoint_gdtc",
"authentik.enterprise.stages.mtls",
"authentik.enterprise.stages.source"
@@ -9084,6 +9197,7 @@
"authentik_providers_ssf.ssfprovider",
"authentik_providers_ws_federation.wsfederationprovider",
"authentik_reports.dataexport",
"authentik_stages_account_lockdown.accountlockdownstage",
"authentik_stages_authenticator_endpoint_gdtc.authenticatorendpointgdtcstage",
"authentik_stages_mtls.mutualtlsstage",
"authentik_stages_source.sourcestage"
@@ -11791,6 +11905,10 @@
"authentik_sources_telegram.view_telegramsource",
"authentik_sources_telegram.view_telegramsourcepropertymapping",
"authentik_sources_telegram.view_usertelegramsourceconnection",
"authentik_stages_account_lockdown.add_accountlockdownstage",
"authentik_stages_account_lockdown.change_accountlockdownstage",
"authentik_stages_account_lockdown.delete_accountlockdownstage",
"authentik_stages_account_lockdown.view_accountlockdownstage",
"authentik_stages_authenticator_duo.add_authenticatorduostage",
"authentik_stages_authenticator_duo.add_duodevice",
"authentik_stages_authenticator_duo.change_authenticatorduostage",
@@ -15657,6 +15775,9 @@
"separator",
"hidden",
"static",
"alert_info",
"alert_warning",
"alert_danger",
"ak-locale"
],
"title": "Type"

View File

@@ -100,6 +100,7 @@ mod json {
);
let mut json_layer = json_subscriber::fmt::layer()
.with_level(false)
.with_timer(LocalTime::new(time_format))
.with_file(true)
.with_line_number(true)
@@ -109,6 +110,11 @@ mod json {
let inner_layer = json_layer.inner_layer_mut();
inner_layer.with_thread_ids("thread_id");
inner_layer.with_thread_names("thread_name");
inner_layer.add_dynamic_field("level", |event, _| {
Some(serde_json::Value::String(
event.metadata().level().as_str().to_lowercase(),
))
});
inner_layer.add_dynamic_field("pid", |_, _| {
Some(serde_json::Value::Number(serde_json::Number::from(
std::process::id(),

View File

@@ -39,6 +39,7 @@ type ApiCoreBrandsListRequest struct {
flowAuthentication *string
flowDeviceCode *string
flowInvalidation *string
flowLockdown *string
flowRecovery *string
flowUnenrollment *string
flowUserSettings *string
@@ -104,6 +105,11 @@ func (r ApiCoreBrandsListRequest) FlowInvalidation(flowInvalidation string) ApiC
return r
}
func (r ApiCoreBrandsListRequest) FlowLockdown(flowLockdown string) ApiCoreBrandsListRequest {
r.flowLockdown = &flowLockdown
return r
}
func (r ApiCoreBrandsListRequest) FlowRecovery(flowRecovery string) ApiCoreBrandsListRequest {
r.flowRecovery = &flowRecovery
return r
@@ -230,6 +236,9 @@ func (a *CoreAPIService) CoreBrandsListExecute(r ApiCoreBrandsListRequest) (*Pag
if r.flowInvalidation != nil {
parameterAddToHeaderOrQuery(localVarQueryParams, "flow_invalidation", r.flowInvalidation, "form", "")
}
if r.flowLockdown != nil {
parameterAddToHeaderOrQuery(localVarQueryParams, "flow_lockdown", r.flowLockdown, "form", "")
}
if r.flowRecovery != nil {
parameterAddToHeaderOrQuery(localVarQueryParams, "flow_recovery", r.flowRecovery, "form", "")
}

View File

@@ -36,6 +36,7 @@ type Brand struct {
FlowUnenrollment NullableString `json:"flow_unenrollment,omitempty"`
FlowUserSettings NullableString `json:"flow_user_settings,omitempty"`
FlowDeviceCode NullableString `json:"flow_device_code,omitempty"`
FlowLockdown NullableString `json:"flow_lockdown,omitempty"`
// When set, external users will be redirected to this application after authenticating.
DefaultApplication NullableString `json:"default_application,omitempty"`
// Web Certificate used by the authentik Core webserver.
@@ -565,6 +566,49 @@ func (o *Brand) UnsetFlowDeviceCode() {
o.FlowDeviceCode.Unset()
}
// GetFlowLockdown returns the FlowLockdown field value if set, zero value otherwise (both if not set or set to explicit null).
func (o *Brand) GetFlowLockdown() string {
if o == nil || IsNil(o.FlowLockdown.Get()) {
var ret string
return ret
}
return *o.FlowLockdown.Get()
}
// GetFlowLockdownOk returns a tuple with the FlowLockdown field value if set, nil otherwise
// and a boolean to check if the value has been set.
// NOTE: If the value is an explicit nil, `nil, true` will be returned
func (o *Brand) GetFlowLockdownOk() (*string, bool) {
if o == nil {
return nil, false
}
return o.FlowLockdown.Get(), o.FlowLockdown.IsSet()
}
// HasFlowLockdown returns a boolean if a field has been set.
func (o *Brand) HasFlowLockdown() bool {
if o != nil && o.FlowLockdown.IsSet() {
return true
}
return false
}
// SetFlowLockdown gets a reference to the given NullableString and assigns it to the FlowLockdown field.
func (o *Brand) SetFlowLockdown(v string) {
o.FlowLockdown.Set(&v)
}
// SetFlowLockdownNil sets the value for FlowLockdown to be an explicit nil
func (o *Brand) SetFlowLockdownNil() {
o.FlowLockdown.Set(nil)
}
// UnsetFlowLockdown ensures that no value is present for FlowLockdown, not even an explicit nil
func (o *Brand) UnsetFlowLockdown() {
o.FlowLockdown.Unset()
}
// GetDefaultApplication returns the DefaultApplication field value if set, zero value otherwise (both if not set or set to explicit null).
func (o *Brand) GetDefaultApplication() string {
if o == nil || IsNil(o.DefaultApplication.Get()) {
@@ -763,6 +807,9 @@ func (o Brand) ToMap() (map[string]interface{}, error) {
if o.FlowDeviceCode.IsSet() {
toSerialize["flow_device_code"] = o.FlowDeviceCode.Get()
}
if o.FlowLockdown.IsSet() {
toSerialize["flow_lockdown"] = o.FlowLockdown.Get()
}
if o.DefaultApplication.IsSet() {
toSerialize["default_application"] = o.DefaultApplication.Get()
}
@@ -833,6 +880,7 @@ func (o *Brand) UnmarshalJSON(data []byte) (err error) {
delete(additionalProperties, "flow_unenrollment")
delete(additionalProperties, "flow_user_settings")
delete(additionalProperties, "flow_device_code")
delete(additionalProperties, "flow_lockdown")
delete(additionalProperties, "default_application")
delete(additionalProperties, "web_certificate")
delete(additionalProperties, "client_certificates")

View File

@@ -38,6 +38,9 @@ const (
PROMPTTYPEENUM_SEPARATOR PromptTypeEnum = "separator"
PROMPTTYPEENUM_HIDDEN PromptTypeEnum = "hidden"
PROMPTTYPEENUM_STATIC PromptTypeEnum = "static"
PROMPTTYPEENUM_ALERT_INFO PromptTypeEnum = "alert_info"
PROMPTTYPEENUM_ALERT_WARNING PromptTypeEnum = "alert_warning"
PROMPTTYPEENUM_ALERT_DANGER PromptTypeEnum = "alert_danger"
PROMPTTYPEENUM_AK_LOCALE PromptTypeEnum = "ak-locale"
)
@@ -60,6 +63,9 @@ var AllowedPromptTypeEnumEnumValues = []PromptTypeEnum{
"separator",
"hidden",
"static",
"alert_info",
"alert_warning",
"alert_danger",
"ak-locale",
}

View File

@@ -71,6 +71,7 @@ pub async fn core_brands_list(
flow_authentication: Option<&str>,
flow_device_code: Option<&str>,
flow_invalidation: Option<&str>,
flow_lockdown: Option<&str>,
flow_recovery: Option<&str>,
flow_unenrollment: Option<&str>,
flow_user_settings: Option<&str>,
@@ -92,6 +93,7 @@ pub async fn core_brands_list(
let p_query_flow_authentication = flow_authentication;
let p_query_flow_device_code = flow_device_code;
let p_query_flow_invalidation = flow_invalidation;
let p_query_flow_lockdown = flow_lockdown;
let p_query_flow_recovery = flow_recovery;
let p_query_flow_unenrollment = flow_unenrollment;
let p_query_flow_user_settings = flow_user_settings;
@@ -154,6 +156,9 @@ pub async fn core_brands_list(
if let Some(ref param_value) = p_query_flow_invalidation {
req_builder = req_builder.query(&[("flow_invalidation", &param_value.to_string())]);
}
if let Some(ref param_value) = p_query_flow_lockdown {
req_builder = req_builder.query(&[("flow_lockdown", &param_value.to_string())]);
}
if let Some(ref param_value) = p_query_flow_recovery {
req_builder = req_builder.query(&[("flow_recovery", &param_value.to_string())]);
}

View File

@@ -78,6 +78,13 @@ pub struct Brand {
skip_serializing_if = "Option::is_none"
)]
pub flow_device_code: Option<Option<uuid::Uuid>>,
#[serde(
rename = "flow_lockdown",
default,
with = "::serde_with::rust::double_option",
skip_serializing_if = "Option::is_none"
)]
pub flow_lockdown: Option<Option<uuid::Uuid>>,
/// When set, external users will be redirected to this application after authenticating.
#[serde(
rename = "default_application",
@@ -122,6 +129,7 @@ impl Brand {
flow_unenrollment: None,
flow_user_settings: None,
flow_device_code: None,
flow_lockdown: None,
default_application: None,
web_certificate: None,
client_certificates: None,

View File

@@ -47,6 +47,12 @@ pub enum PromptTypeEnum {
Hidden,
#[serde(rename = "static")]
Static,
#[serde(rename = "alert_info")]
AlertInfo,
#[serde(rename = "alert_warning")]
AlertWarning,
#[serde(rename = "alert_danger")]
AlertDanger,
#[serde(rename = "ak-locale")]
AkLocale,
}
@@ -71,6 +77,9 @@ impl std::fmt::Display for PromptTypeEnum {
Self::Separator => write!(f, "separator"),
Self::Hidden => write!(f, "hidden"),
Self::Static => write!(f, "static"),
Self::AlertInfo => write!(f, "alert_info"),
Self::AlertWarning => write!(f, "alert_warning"),
Self::AlertDanger => write!(f, "alert_danger"),
Self::AkLocale => write!(f, "ak-locale"),
}
}

View File

@@ -52,6 +52,7 @@ import type {
TransactionApplicationResponse,
UsedBy,
User,
UserAccountLockdownRequest,
UserAccountRequest,
UserConsent,
UserPasswordHashSetRequest,
@@ -102,6 +103,7 @@ import {
TransactionApplicationRequestToJSON,
TransactionApplicationResponseFromJSON,
UsedByFromJSON,
UserAccountLockdownRequestToJSON,
UserAccountRequestToJSON,
UserConsentFromJSON,
UserFromJSON,
@@ -245,6 +247,7 @@ export interface CoreBrandsListRequest {
flowAuthentication?: string;
flowDeviceCode?: string;
flowInvalidation?: string;
flowLockdown?: string;
flowRecovery?: string;
flowUnenrollment?: string;
flowUserSettings?: string;
@@ -403,6 +406,10 @@ export interface CoreUserConsentUsedByListRequest {
id: number;
}
export interface CoreUsersAccountLockdownCreateRequest {
userAccountLockdownRequest?: UserAccountLockdownRequest;
}
export interface CoreUsersCreateRequest {
userRequest: UserRequest;
}
@@ -2214,6 +2221,10 @@ export class CoreApi extends runtime.BaseAPI {
queryParameters["flow_invalidation"] = requestParameters["flowInvalidation"];
}
if (requestParameters["flowLockdown"] != null) {
queryParameters["flow_lockdown"] = requestParameters["flowLockdown"];
}
if (requestParameters["flowRecovery"] != null) {
queryParameters["flow_recovery"] = requestParameters["flowRecovery"];
}
@@ -4189,6 +4200,66 @@ export class CoreApi extends runtime.BaseAPI {
return await response.value();
}
/**
* Creates request options for coreUsersAccountLockdownCreate without sending the request
*/
async coreUsersAccountLockdownCreateRequestOpts(
requestParameters: CoreUsersAccountLockdownCreateRequest,
): Promise<runtime.RequestOpts> {
const queryParameters: any = {};
const headerParameters: runtime.HTTPHeaders = {};
headerParameters["Content-Type"] = "application/json";
if (this.configuration && this.configuration.accessToken) {
const token = this.configuration.accessToken;
const tokenString = await token("authentik", []);
if (tokenString) {
headerParameters["Authorization"] = `Bearer ${tokenString}`;
}
}
let urlPath = `/core/users/account_lockdown/`;
return {
path: urlPath,
method: "POST",
headers: headerParameters,
query: queryParameters,
body: UserAccountLockdownRequestToJSON(requestParameters["userAccountLockdownRequest"]),
};
}
/**
* Choose the target account, then return a flow link.
*/
async coreUsersAccountLockdownCreateRaw(
requestParameters: CoreUsersAccountLockdownCreateRequest,
initOverrides?: RequestInit | runtime.InitOverrideFunction,
): Promise<runtime.ApiResponse<Link>> {
const requestOptions =
await this.coreUsersAccountLockdownCreateRequestOpts(requestParameters);
const response = await this.request(requestOptions, initOverrides);
return new runtime.JSONApiResponse(response, (jsonValue) => LinkFromJSON(jsonValue));
}
/**
* Choose the target account, then return a flow link.
*/
async coreUsersAccountLockdownCreate(
requestParameters: CoreUsersAccountLockdownCreateRequest = {},
initOverrides?: RequestInit | runtime.InitOverrideFunction,
): Promise<Link> {
const response = await this.coreUsersAccountLockdownCreateRaw(
requestParameters,
initOverrides,
);
return await response.value();
}
/**
* Creates request options for coreUsersCreate without sending the request
*/

View File

@@ -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<runtime.RequestOpts> {
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<runtime.ApiResponse<LifecycleIteration>> {
): Promise<runtime.ApiResponse<Array<LifecycleIteration>>> {
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<LifecycleIteration> {
const response = await this.lifecycleIterationsLatestRetrieveRaw(
): Promise<Array<LifecycleIteration>> {
const response = await this.lifecycleIterationsListLatestRaw(
requestParameters,
initOverrides,
);

View File

@@ -13,6 +13,8 @@
*/
import type {
AccountLockdownStage,
AccountLockdownStageRequest,
AuthenticatorAttachmentEnum,
AuthenticatorDuoStage,
AuthenticatorDuoStageDeviceImportResponse,
@@ -61,6 +63,7 @@ import type {
MutualTLSStageRequest,
NetworkBindingEnum,
NotConfiguredActionEnum,
PaginatedAccountLockdownStageList,
PaginatedAuthenticatorDuoStageList,
PaginatedAuthenticatorEmailStageList,
PaginatedAuthenticatorEndpointGDTCStageList,
@@ -92,6 +95,7 @@ import type {
PaginatedWebAuthnDeviceTypeList,
PasswordStage,
PasswordStageRequest,
PatchedAccountLockdownStageRequest,
PatchedAuthenticatorDuoStageRequest,
PatchedAuthenticatorEmailStageRequest,
PatchedAuthenticatorEndpointGDTCStageRequest,
@@ -150,6 +154,8 @@ import type {
WebAuthnDeviceType,
} from "../models/index";
import {
AccountLockdownStageFromJSON,
AccountLockdownStageRequestToJSON,
AuthenticatorDuoStageDeviceImportResponseFromJSON,
AuthenticatorDuoStageFromJSON,
AuthenticatorDuoStageManualDeviceImportRequestToJSON,
@@ -190,6 +196,7 @@ import {
InvitationStageRequestToJSON,
MutualTLSStageFromJSON,
MutualTLSStageRequestToJSON,
PaginatedAccountLockdownStageListFromJSON,
PaginatedAuthenticatorDuoStageListFromJSON,
PaginatedAuthenticatorEmailStageListFromJSON,
PaginatedAuthenticatorEndpointGDTCStageListFromJSON,
@@ -221,6 +228,7 @@ import {
PaginatedWebAuthnDeviceTypeListFromJSON,
PasswordStageFromJSON,
PasswordStageRequestToJSON,
PatchedAccountLockdownStageRequestToJSON,
PatchedAuthenticatorDuoStageRequestToJSON,
PatchedAuthenticatorEmailStageRequestToJSON,
PatchedAuthenticatorEndpointGDTCStageRequestToJSON,
@@ -273,6 +281,46 @@ import {
} from "../models/index";
import * as runtime from "../runtime";
export interface StagesAccountLockdownCreateRequest {
accountLockdownStageRequest: AccountLockdownStageRequest;
}
export interface StagesAccountLockdownDestroyRequest {
stageUuid: string;
}
export interface StagesAccountLockdownListRequest {
deactivateUser?: boolean;
deleteSessions?: boolean;
name?: string;
ordering?: string;
page?: number;
pageSize?: number;
revokeTokens?: boolean;
search?: string;
selfServiceCompletionFlow?: string;
setUnusablePassword?: boolean;
stageUuid?: string;
}
export interface StagesAccountLockdownPartialUpdateRequest {
stageUuid: string;
patchedAccountLockdownStageRequest?: PatchedAccountLockdownStageRequest;
}
export interface StagesAccountLockdownRetrieveRequest {
stageUuid: string;
}
export interface StagesAccountLockdownUpdateRequest {
stageUuid: string;
accountLockdownStageRequest: AccountLockdownStageRequest;
}
export interface StagesAccountLockdownUsedByListRequest {
stageUuid: string;
}
export interface StagesAllDestroyRequest {
stageUuid: string;
}
@@ -1366,6 +1414,534 @@ export interface StagesUserWriteUsedByListRequest {
*
*/
export class StagesApi extends runtime.BaseAPI {
/**
* Creates request options for stagesAccountLockdownCreate without sending the request
*/
async stagesAccountLockdownCreateRequestOpts(
requestParameters: StagesAccountLockdownCreateRequest,
): Promise<runtime.RequestOpts> {
if (requestParameters["accountLockdownStageRequest"] == null) {
throw new runtime.RequiredError(
"accountLockdownStageRequest",
'Required parameter "accountLockdownStageRequest" was null or undefined when calling stagesAccountLockdownCreate().',
);
}
const queryParameters: any = {};
const headerParameters: runtime.HTTPHeaders = {};
headerParameters["Content-Type"] = "application/json";
if (this.configuration && this.configuration.accessToken) {
const token = this.configuration.accessToken;
const tokenString = await token("authentik", []);
if (tokenString) {
headerParameters["Authorization"] = `Bearer ${tokenString}`;
}
}
let urlPath = `/stages/account_lockdown/`;
return {
path: urlPath,
method: "POST",
headers: headerParameters,
query: queryParameters,
body: AccountLockdownStageRequestToJSON(
requestParameters["accountLockdownStageRequest"],
),
};
}
/**
* AccountLockdownStage Viewset
*/
async stagesAccountLockdownCreateRaw(
requestParameters: StagesAccountLockdownCreateRequest,
initOverrides?: RequestInit | runtime.InitOverrideFunction,
): Promise<runtime.ApiResponse<AccountLockdownStage>> {
const requestOptions = await this.stagesAccountLockdownCreateRequestOpts(requestParameters);
const response = await this.request(requestOptions, initOverrides);
return new runtime.JSONApiResponse(response, (jsonValue) =>
AccountLockdownStageFromJSON(jsonValue),
);
}
/**
* AccountLockdownStage Viewset
*/
async stagesAccountLockdownCreate(
requestParameters: StagesAccountLockdownCreateRequest,
initOverrides?: RequestInit | runtime.InitOverrideFunction,
): Promise<AccountLockdownStage> {
const response = await this.stagesAccountLockdownCreateRaw(
requestParameters,
initOverrides,
);
return await response.value();
}
/**
* Creates request options for stagesAccountLockdownDestroy without sending the request
*/
async stagesAccountLockdownDestroyRequestOpts(
requestParameters: StagesAccountLockdownDestroyRequest,
): Promise<runtime.RequestOpts> {
if (requestParameters["stageUuid"] == null) {
throw new runtime.RequiredError(
"stageUuid",
'Required parameter "stageUuid" was null or undefined when calling stagesAccountLockdownDestroy().',
);
}
const queryParameters: any = {};
const headerParameters: runtime.HTTPHeaders = {};
if (this.configuration && this.configuration.accessToken) {
const token = this.configuration.accessToken;
const tokenString = await token("authentik", []);
if (tokenString) {
headerParameters["Authorization"] = `Bearer ${tokenString}`;
}
}
let urlPath = `/stages/account_lockdown/{stage_uuid}/`;
urlPath = urlPath.replace(
`{${"stage_uuid"}}`,
encodeURIComponent(String(requestParameters["stageUuid"])),
);
return {
path: urlPath,
method: "DELETE",
headers: headerParameters,
query: queryParameters,
};
}
/**
* AccountLockdownStage Viewset
*/
async stagesAccountLockdownDestroyRaw(
requestParameters: StagesAccountLockdownDestroyRequest,
initOverrides?: RequestInit | runtime.InitOverrideFunction,
): Promise<runtime.ApiResponse<void>> {
const requestOptions =
await this.stagesAccountLockdownDestroyRequestOpts(requestParameters);
const response = await this.request(requestOptions, initOverrides);
return new runtime.VoidApiResponse(response);
}
/**
* AccountLockdownStage Viewset
*/
async stagesAccountLockdownDestroy(
requestParameters: StagesAccountLockdownDestroyRequest,
initOverrides?: RequestInit | runtime.InitOverrideFunction,
): Promise<void> {
await this.stagesAccountLockdownDestroyRaw(requestParameters, initOverrides);
}
/**
* Creates request options for stagesAccountLockdownList without sending the request
*/
async stagesAccountLockdownListRequestOpts(
requestParameters: StagesAccountLockdownListRequest,
): Promise<runtime.RequestOpts> {
const queryParameters: any = {};
if (requestParameters["deactivateUser"] != null) {
queryParameters["deactivate_user"] = requestParameters["deactivateUser"];
}
if (requestParameters["deleteSessions"] != null) {
queryParameters["delete_sessions"] = requestParameters["deleteSessions"];
}
if (requestParameters["name"] != null) {
queryParameters["name"] = requestParameters["name"];
}
if (requestParameters["ordering"] != null) {
queryParameters["ordering"] = requestParameters["ordering"];
}
if (requestParameters["page"] != null) {
queryParameters["page"] = requestParameters["page"];
}
if (requestParameters["pageSize"] != null) {
queryParameters["page_size"] = requestParameters["pageSize"];
}
if (requestParameters["revokeTokens"] != null) {
queryParameters["revoke_tokens"] = requestParameters["revokeTokens"];
}
if (requestParameters["search"] != null) {
queryParameters["search"] = requestParameters["search"];
}
if (requestParameters["selfServiceCompletionFlow"] != null) {
queryParameters["self_service_completion_flow"] =
requestParameters["selfServiceCompletionFlow"];
}
if (requestParameters["setUnusablePassword"] != null) {
queryParameters["set_unusable_password"] = requestParameters["setUnusablePassword"];
}
if (requestParameters["stageUuid"] != null) {
queryParameters["stage_uuid"] = requestParameters["stageUuid"];
}
const headerParameters: runtime.HTTPHeaders = {};
if (this.configuration && this.configuration.accessToken) {
const token = this.configuration.accessToken;
const tokenString = await token("authentik", []);
if (tokenString) {
headerParameters["Authorization"] = `Bearer ${tokenString}`;
}
}
let urlPath = `/stages/account_lockdown/`;
return {
path: urlPath,
method: "GET",
headers: headerParameters,
query: queryParameters,
};
}
/**
* AccountLockdownStage Viewset
*/
async stagesAccountLockdownListRaw(
requestParameters: StagesAccountLockdownListRequest,
initOverrides?: RequestInit | runtime.InitOverrideFunction,
): Promise<runtime.ApiResponse<PaginatedAccountLockdownStageList>> {
const requestOptions = await this.stagesAccountLockdownListRequestOpts(requestParameters);
const response = await this.request(requestOptions, initOverrides);
return new runtime.JSONApiResponse(response, (jsonValue) =>
PaginatedAccountLockdownStageListFromJSON(jsonValue),
);
}
/**
* AccountLockdownStage Viewset
*/
async stagesAccountLockdownList(
requestParameters: StagesAccountLockdownListRequest = {},
initOverrides?: RequestInit | runtime.InitOverrideFunction,
): Promise<PaginatedAccountLockdownStageList> {
const response = await this.stagesAccountLockdownListRaw(requestParameters, initOverrides);
return await response.value();
}
/**
* Creates request options for stagesAccountLockdownPartialUpdate without sending the request
*/
async stagesAccountLockdownPartialUpdateRequestOpts(
requestParameters: StagesAccountLockdownPartialUpdateRequest,
): Promise<runtime.RequestOpts> {
if (requestParameters["stageUuid"] == null) {
throw new runtime.RequiredError(
"stageUuid",
'Required parameter "stageUuid" was null or undefined when calling stagesAccountLockdownPartialUpdate().',
);
}
const queryParameters: any = {};
const headerParameters: runtime.HTTPHeaders = {};
headerParameters["Content-Type"] = "application/json";
if (this.configuration && this.configuration.accessToken) {
const token = this.configuration.accessToken;
const tokenString = await token("authentik", []);
if (tokenString) {
headerParameters["Authorization"] = `Bearer ${tokenString}`;
}
}
let urlPath = `/stages/account_lockdown/{stage_uuid}/`;
urlPath = urlPath.replace(
`{${"stage_uuid"}}`,
encodeURIComponent(String(requestParameters["stageUuid"])),
);
return {
path: urlPath,
method: "PATCH",
headers: headerParameters,
query: queryParameters,
body: PatchedAccountLockdownStageRequestToJSON(
requestParameters["patchedAccountLockdownStageRequest"],
),
};
}
/**
* AccountLockdownStage Viewset
*/
async stagesAccountLockdownPartialUpdateRaw(
requestParameters: StagesAccountLockdownPartialUpdateRequest,
initOverrides?: RequestInit | runtime.InitOverrideFunction,
): Promise<runtime.ApiResponse<AccountLockdownStage>> {
const requestOptions =
await this.stagesAccountLockdownPartialUpdateRequestOpts(requestParameters);
const response = await this.request(requestOptions, initOverrides);
return new runtime.JSONApiResponse(response, (jsonValue) =>
AccountLockdownStageFromJSON(jsonValue),
);
}
/**
* AccountLockdownStage Viewset
*/
async stagesAccountLockdownPartialUpdate(
requestParameters: StagesAccountLockdownPartialUpdateRequest,
initOverrides?: RequestInit | runtime.InitOverrideFunction,
): Promise<AccountLockdownStage> {
const response = await this.stagesAccountLockdownPartialUpdateRaw(
requestParameters,
initOverrides,
);
return await response.value();
}
/**
* Creates request options for stagesAccountLockdownRetrieve without sending the request
*/
async stagesAccountLockdownRetrieveRequestOpts(
requestParameters: StagesAccountLockdownRetrieveRequest,
): Promise<runtime.RequestOpts> {
if (requestParameters["stageUuid"] == null) {
throw new runtime.RequiredError(
"stageUuid",
'Required parameter "stageUuid" was null or undefined when calling stagesAccountLockdownRetrieve().',
);
}
const queryParameters: any = {};
const headerParameters: runtime.HTTPHeaders = {};
if (this.configuration && this.configuration.accessToken) {
const token = this.configuration.accessToken;
const tokenString = await token("authentik", []);
if (tokenString) {
headerParameters["Authorization"] = `Bearer ${tokenString}`;
}
}
let urlPath = `/stages/account_lockdown/{stage_uuid}/`;
urlPath = urlPath.replace(
`{${"stage_uuid"}}`,
encodeURIComponent(String(requestParameters["stageUuid"])),
);
return {
path: urlPath,
method: "GET",
headers: headerParameters,
query: queryParameters,
};
}
/**
* AccountLockdownStage Viewset
*/
async stagesAccountLockdownRetrieveRaw(
requestParameters: StagesAccountLockdownRetrieveRequest,
initOverrides?: RequestInit | runtime.InitOverrideFunction,
): Promise<runtime.ApiResponse<AccountLockdownStage>> {
const requestOptions =
await this.stagesAccountLockdownRetrieveRequestOpts(requestParameters);
const response = await this.request(requestOptions, initOverrides);
return new runtime.JSONApiResponse(response, (jsonValue) =>
AccountLockdownStageFromJSON(jsonValue),
);
}
/**
* AccountLockdownStage Viewset
*/
async stagesAccountLockdownRetrieve(
requestParameters: StagesAccountLockdownRetrieveRequest,
initOverrides?: RequestInit | runtime.InitOverrideFunction,
): Promise<AccountLockdownStage> {
const response = await this.stagesAccountLockdownRetrieveRaw(
requestParameters,
initOverrides,
);
return await response.value();
}
/**
* Creates request options for stagesAccountLockdownUpdate without sending the request
*/
async stagesAccountLockdownUpdateRequestOpts(
requestParameters: StagesAccountLockdownUpdateRequest,
): Promise<runtime.RequestOpts> {
if (requestParameters["stageUuid"] == null) {
throw new runtime.RequiredError(
"stageUuid",
'Required parameter "stageUuid" was null or undefined when calling stagesAccountLockdownUpdate().',
);
}
if (requestParameters["accountLockdownStageRequest"] == null) {
throw new runtime.RequiredError(
"accountLockdownStageRequest",
'Required parameter "accountLockdownStageRequest" was null or undefined when calling stagesAccountLockdownUpdate().',
);
}
const queryParameters: any = {};
const headerParameters: runtime.HTTPHeaders = {};
headerParameters["Content-Type"] = "application/json";
if (this.configuration && this.configuration.accessToken) {
const token = this.configuration.accessToken;
const tokenString = await token("authentik", []);
if (tokenString) {
headerParameters["Authorization"] = `Bearer ${tokenString}`;
}
}
let urlPath = `/stages/account_lockdown/{stage_uuid}/`;
urlPath = urlPath.replace(
`{${"stage_uuid"}}`,
encodeURIComponent(String(requestParameters["stageUuid"])),
);
return {
path: urlPath,
method: "PUT",
headers: headerParameters,
query: queryParameters,
body: AccountLockdownStageRequestToJSON(
requestParameters["accountLockdownStageRequest"],
),
};
}
/**
* AccountLockdownStage Viewset
*/
async stagesAccountLockdownUpdateRaw(
requestParameters: StagesAccountLockdownUpdateRequest,
initOverrides?: RequestInit | runtime.InitOverrideFunction,
): Promise<runtime.ApiResponse<AccountLockdownStage>> {
const requestOptions = await this.stagesAccountLockdownUpdateRequestOpts(requestParameters);
const response = await this.request(requestOptions, initOverrides);
return new runtime.JSONApiResponse(response, (jsonValue) =>
AccountLockdownStageFromJSON(jsonValue),
);
}
/**
* AccountLockdownStage Viewset
*/
async stagesAccountLockdownUpdate(
requestParameters: StagesAccountLockdownUpdateRequest,
initOverrides?: RequestInit | runtime.InitOverrideFunction,
): Promise<AccountLockdownStage> {
const response = await this.stagesAccountLockdownUpdateRaw(
requestParameters,
initOverrides,
);
return await response.value();
}
/**
* Creates request options for stagesAccountLockdownUsedByList without sending the request
*/
async stagesAccountLockdownUsedByListRequestOpts(
requestParameters: StagesAccountLockdownUsedByListRequest,
): Promise<runtime.RequestOpts> {
if (requestParameters["stageUuid"] == null) {
throw new runtime.RequiredError(
"stageUuid",
'Required parameter "stageUuid" was null or undefined when calling stagesAccountLockdownUsedByList().',
);
}
const queryParameters: any = {};
const headerParameters: runtime.HTTPHeaders = {};
if (this.configuration && this.configuration.accessToken) {
const token = this.configuration.accessToken;
const tokenString = await token("authentik", []);
if (tokenString) {
headerParameters["Authorization"] = `Bearer ${tokenString}`;
}
}
let urlPath = `/stages/account_lockdown/{stage_uuid}/used_by/`;
urlPath = urlPath.replace(
`{${"stage_uuid"}}`,
encodeURIComponent(String(requestParameters["stageUuid"])),
);
return {
path: urlPath,
method: "GET",
headers: headerParameters,
query: queryParameters,
};
}
/**
* Get a list of all objects that use this object
*/
async stagesAccountLockdownUsedByListRaw(
requestParameters: StagesAccountLockdownUsedByListRequest,
initOverrides?: RequestInit | runtime.InitOverrideFunction,
): Promise<runtime.ApiResponse<Array<UsedBy>>> {
const requestOptions =
await this.stagesAccountLockdownUsedByListRequestOpts(requestParameters);
const response = await this.request(requestOptions, initOverrides);
return new runtime.JSONApiResponse(response, (jsonValue) => jsonValue.map(UsedByFromJSON));
}
/**
* Get a list of all objects that use this object
*/
async stagesAccountLockdownUsedByList(
requestParameters: StagesAccountLockdownUsedByListRequest,
initOverrides?: RequestInit | runtime.InitOverrideFunction,
): Promise<Array<UsedBy>> {
const response = await this.stagesAccountLockdownUsedByListRaw(
requestParameters,
initOverrides,
);
return await response.value();
}
/**
* Creates request options for stagesAllDestroy without sending the request
*/

View File

@@ -0,0 +1,166 @@
/* 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 { FlowSet } from "./FlowSet";
import { FlowSetFromJSON } from "./FlowSet";
/**
* AccountLockdownStage Serializer
* @export
* @interface AccountLockdownStage
*/
export interface AccountLockdownStage {
/**
*
* @type {string}
* @memberof AccountLockdownStage
*/
readonly pk: string;
/**
*
* @type {string}
* @memberof AccountLockdownStage
*/
name: string;
/**
* Get object type so that we know how to edit the object
* @type {string}
* @memberof AccountLockdownStage
*/
readonly component: string;
/**
* Return object's verbose_name
* @type {string}
* @memberof AccountLockdownStage
*/
readonly verboseName: string;
/**
* Return object's plural verbose_name
* @type {string}
* @memberof AccountLockdownStage
*/
readonly verboseNamePlural: string;
/**
* Return internal model name
* @type {string}
* @memberof AccountLockdownStage
*/
readonly metaModelName: string;
/**
*
* @type {Array<FlowSet>}
* @memberof AccountLockdownStage
*/
readonly flowSet: Array<FlowSet>;
/**
* Deactivate the user account (set is_active to False)
* @type {boolean}
* @memberof AccountLockdownStage
*/
deactivateUser?: boolean;
/**
* Set an unusable password for the user
* @type {boolean}
* @memberof AccountLockdownStage
*/
setUnusablePassword?: boolean;
/**
* Delete all active sessions for the user
* @type {boolean}
* @memberof AccountLockdownStage
*/
deleteSessions?: boolean;
/**
* Revoke all tokens for the user (API, app password, recovery, verification, OAuth)
* @type {boolean}
* @memberof AccountLockdownStage
*/
revokeTokens?: boolean;
/**
* Flow to redirect users to after self-service lockdown. This flow should not require authentication since the user's session is deleted.
* @type {string}
* @memberof AccountLockdownStage
*/
selfServiceCompletionFlow?: string | null;
}
/**
* Check if a given object implements the AccountLockdownStage interface.
*/
export function instanceOfAccountLockdownStage(value: object): value is AccountLockdownStage {
if (!("pk" in value) || value["pk"] === undefined) return false;
if (!("name" in value) || value["name"] === undefined) return false;
if (!("component" in value) || value["component"] === undefined) return false;
if (!("verboseName" in value) || value["verboseName"] === undefined) return false;
if (!("verboseNamePlural" in value) || value["verboseNamePlural"] === undefined) return false;
if (!("metaModelName" in value) || value["metaModelName"] === undefined) return false;
if (!("flowSet" in value) || value["flowSet"] === undefined) return false;
return true;
}
export function AccountLockdownStageFromJSON(json: any): AccountLockdownStage {
return AccountLockdownStageFromJSONTyped(json, false);
}
export function AccountLockdownStageFromJSONTyped(
json: any,
ignoreDiscriminator: boolean,
): AccountLockdownStage {
if (json == null) {
return json;
}
return {
pk: json["pk"],
name: json["name"],
component: json["component"],
verboseName: json["verbose_name"],
verboseNamePlural: json["verbose_name_plural"],
metaModelName: json["meta_model_name"],
flowSet: (json["flow_set"] as Array<any>).map(FlowSetFromJSON),
deactivateUser: json["deactivate_user"] == null ? undefined : json["deactivate_user"],
setUnusablePassword:
json["set_unusable_password"] == null ? undefined : json["set_unusable_password"],
deleteSessions: json["delete_sessions"] == null ? undefined : json["delete_sessions"],
revokeTokens: json["revoke_tokens"] == null ? undefined : json["revoke_tokens"],
selfServiceCompletionFlow:
json["self_service_completion_flow"] == null
? undefined
: json["self_service_completion_flow"],
};
}
export function AccountLockdownStageToJSON(json: any): AccountLockdownStage {
return AccountLockdownStageToJSONTyped(json, false);
}
export function AccountLockdownStageToJSONTyped(
value?: Omit<
AccountLockdownStage,
"pk" | "component" | "verbose_name" | "verbose_name_plural" | "meta_model_name" | "flow_set"
> | null,
ignoreDiscriminator: boolean = false,
): any {
if (value == null) {
return value;
}
return {
name: value["name"],
deactivate_user: value["deactivateUser"],
set_unusable_password: value["setUnusablePassword"],
delete_sessions: value["deleteSessions"],
revoke_tokens: value["revokeTokens"],
self_service_completion_flow: value["selfServiceCompletionFlow"],
};
}

View File

@@ -0,0 +1,114 @@
/* 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.
*/
/**
* AccountLockdownStage Serializer
* @export
* @interface AccountLockdownStageRequest
*/
export interface AccountLockdownStageRequest {
/**
*
* @type {string}
* @memberof AccountLockdownStageRequest
*/
name: string;
/**
* Deactivate the user account (set is_active to False)
* @type {boolean}
* @memberof AccountLockdownStageRequest
*/
deactivateUser?: boolean;
/**
* Set an unusable password for the user
* @type {boolean}
* @memberof AccountLockdownStageRequest
*/
setUnusablePassword?: boolean;
/**
* Delete all active sessions for the user
* @type {boolean}
* @memberof AccountLockdownStageRequest
*/
deleteSessions?: boolean;
/**
* Revoke all tokens for the user (API, app password, recovery, verification, OAuth)
* @type {boolean}
* @memberof AccountLockdownStageRequest
*/
revokeTokens?: boolean;
/**
* Flow to redirect users to after self-service lockdown. This flow should not require authentication since the user's session is deleted.
* @type {string}
* @memberof AccountLockdownStageRequest
*/
selfServiceCompletionFlow?: string | null;
}
/**
* Check if a given object implements the AccountLockdownStageRequest interface.
*/
export function instanceOfAccountLockdownStageRequest(
value: object,
): value is AccountLockdownStageRequest {
if (!("name" in value) || value["name"] === undefined) return false;
return true;
}
export function AccountLockdownStageRequestFromJSON(json: any): AccountLockdownStageRequest {
return AccountLockdownStageRequestFromJSONTyped(json, false);
}
export function AccountLockdownStageRequestFromJSONTyped(
json: any,
ignoreDiscriminator: boolean,
): AccountLockdownStageRequest {
if (json == null) {
return json;
}
return {
name: json["name"],
deactivateUser: json["deactivate_user"] == null ? undefined : json["deactivate_user"],
setUnusablePassword:
json["set_unusable_password"] == null ? undefined : json["set_unusable_password"],
deleteSessions: json["delete_sessions"] == null ? undefined : json["delete_sessions"],
revokeTokens: json["revoke_tokens"] == null ? undefined : json["revoke_tokens"],
selfServiceCompletionFlow:
json["self_service_completion_flow"] == null
? undefined
: json["self_service_completion_flow"],
};
}
export function AccountLockdownStageRequestToJSON(json: any): AccountLockdownStageRequest {
return AccountLockdownStageRequestToJSONTyped(json, false);
}
export function AccountLockdownStageRequestToJSONTyped(
value?: AccountLockdownStageRequest | null,
ignoreDiscriminator: boolean = false,
): any {
if (value == null) {
return value;
}
return {
name: value["name"],
deactivate_user: value["deactivateUser"],
set_unusable_password: value["setUnusablePassword"],
delete_sessions: value["deleteSessions"],
revoke_tokens: value["revokeTokens"],
self_service_completion_flow: value["selfServiceCompletionFlow"],
};
}

View File

@@ -94,6 +94,7 @@ export const AppEnum = {
AuthentikEnterpriseProvidersSsf: "authentik.enterprise.providers.ssf",
AuthentikEnterpriseProvidersWsFederation: "authentik.enterprise.providers.ws_federation",
AuthentikEnterpriseReports: "authentik.enterprise.reports",
AuthentikEnterpriseStagesAccountLockdown: "authentik.enterprise.stages.account_lockdown",
AuthentikEnterpriseStagesAuthenticatorEndpointGdtc:
"authentik.enterprise.stages.authenticator_endpoint_gdtc",
AuthentikEnterpriseStagesMtls: "authentik.enterprise.stages.mtls",

View File

@@ -102,6 +102,12 @@ export interface Brand {
* @memberof Brand
*/
flowDeviceCode?: string | null;
/**
*
* @type {string}
* @memberof Brand
*/
flowLockdown?: string | null;
/**
* When set, external users will be redirected to this application after authenticating.
* @type {string}
@@ -166,6 +172,7 @@ export function BrandFromJSONTyped(json: any, ignoreDiscriminator: boolean): Bra
flowUserSettings:
json["flow_user_settings"] == null ? undefined : json["flow_user_settings"],
flowDeviceCode: json["flow_device_code"] == null ? undefined : json["flow_device_code"],
flowLockdown: json["flow_lockdown"] == null ? undefined : json["flow_lockdown"],
defaultApplication:
json["default_application"] == null ? undefined : json["default_application"],
webCertificate: json["web_certificate"] == null ? undefined : json["web_certificate"],
@@ -201,6 +208,7 @@ export function BrandToJSONTyped(
flow_unenrollment: value["flowUnenrollment"],
flow_user_settings: value["flowUserSettings"],
flow_device_code: value["flowDeviceCode"],
flow_lockdown: value["flowLockdown"],
default_application: value["defaultApplication"],
web_certificate: value["webCertificate"],
client_certificates: value["clientCertificates"],

View File

@@ -96,6 +96,12 @@ export interface BrandRequest {
* @memberof BrandRequest
*/
flowDeviceCode?: string | null;
/**
*
* @type {string}
* @memberof BrandRequest
*/
flowLockdown?: string | null;
/**
* When set, external users will be redirected to this application after authenticating.
* @type {string}
@@ -158,6 +164,7 @@ export function BrandRequestFromJSONTyped(json: any, ignoreDiscriminator: boolea
flowUserSettings:
json["flow_user_settings"] == null ? undefined : json["flow_user_settings"],
flowDeviceCode: json["flow_device_code"] == null ? undefined : json["flow_device_code"],
flowLockdown: json["flow_lockdown"] == null ? undefined : json["flow_lockdown"],
defaultApplication:
json["default_application"] == null ? undefined : json["default_application"],
webCertificate: json["web_certificate"] == null ? undefined : json["web_certificate"],
@@ -193,6 +200,7 @@ export function BrandRequestToJSONTyped(
flow_unenrollment: value["flowUnenrollment"],
flow_user_settings: value["flowUserSettings"],
flow_device_code: value["flowDeviceCode"],
flow_lockdown: value["flowLockdown"],
default_application: value["defaultApplication"],
web_certificate: value["webCertificate"],
client_certificates: value["clientCertificates"],

View File

@@ -117,6 +117,12 @@ export interface CurrentBrand {
* @memberof CurrentBrand
*/
flowDeviceCode?: string;
/**
*
* @type {string}
* @memberof CurrentBrand
*/
flowLockdown?: string;
/**
*
* @type {string}
@@ -177,6 +183,7 @@ export function CurrentBrandFromJSONTyped(json: any, ignoreDiscriminator: boolea
flowUserSettings:
json["flow_user_settings"] == null ? undefined : json["flow_user_settings"],
flowDeviceCode: json["flow_device_code"] == null ? undefined : json["flow_device_code"],
flowLockdown: json["flow_lockdown"] == null ? undefined : json["flow_lockdown"],
defaultLocale: json["default_locale"],
flags: CurrentBrandFlagsFromJSON(json["flags"]),
};
@@ -213,6 +220,7 @@ export function CurrentBrandToJSONTyped(
flow_unenrollment: value["flowUnenrollment"],
flow_user_settings: value["flowUserSettings"],
flow_device_code: value["flowDeviceCode"],
flow_lockdown: value["flowLockdown"],
flags: CurrentBrandFlagsToJSON(value["flags"]),
};
}

View File

@@ -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<Review>;
/**
*
* @type {RelatedRule}
* @memberof LifecycleIteration
*/
readonly rule: RelatedRule;
/**
*
* @type {boolean}
* @memberof LifecycleIteration
*/
readonly userCanReview: boolean;
/**
*
* @type {Array<ReviewerGroup>}
* @memberof LifecycleIteration
*/
readonly reviewerGroups: Array<ReviewerGroup>;
/**
*
* @type {number}
* @memberof LifecycleIteration
*/
readonly minReviewers: number;
/**
*
* @type {Array<ReviewerUser>}
* @memberof LifecycleIteration
*/
readonly reviewers: Array<ReviewerUser>;
}
/**
@@ -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<any>).map(ReviewFromJSON),
rule: RelatedRuleFromJSON(json["rule"]),
userCanReview: json["user_can_review"],
reviewerGroups: (json["reviewer_groups"] as Array<any>).map(ReviewerGroupFromJSON),
minReviewers: json["min_reviewers"],
reviewers: (json["reviewers"] as Array<any>).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 {

View File

@@ -175,6 +175,8 @@ export const ModelEnum = {
AuthentikProvidersWsFederationWsfederationprovider:
"authentik_providers_ws_federation.wsfederationprovider",
AuthentikReportsDataexport: "authentik_reports.dataexport",
AuthentikStagesAccountLockdownAccountlockdownstage:
"authentik_stages_account_lockdown.accountlockdownstage",
AuthentikStagesAuthenticatorEndpointGdtcAuthenticatorendpointgdtcstage:
"authentik_stages_authenticator_endpoint_gdtc.authenticatorendpointgdtcstage",
AuthentikStagesMtlsMutualtlsstage: "authentik_stages_mtls.mutualtlsstage",

View File

@@ -0,0 +1,97 @@
/* 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 { AccountLockdownStage } from "./AccountLockdownStage";
import { AccountLockdownStageFromJSON, AccountLockdownStageToJSON } from "./AccountLockdownStage";
import type { Pagination } from "./Pagination";
import { PaginationFromJSON, PaginationToJSON } from "./Pagination";
/**
*
* @export
* @interface PaginatedAccountLockdownStageList
*/
export interface PaginatedAccountLockdownStageList {
/**
*
* @type {Pagination}
* @memberof PaginatedAccountLockdownStageList
*/
pagination: Pagination;
/**
*
* @type {Array<AccountLockdownStage>}
* @memberof PaginatedAccountLockdownStageList
*/
results: Array<AccountLockdownStage>;
/**
*
* @type {{ [key: string]: any; }}
* @memberof PaginatedAccountLockdownStageList
*/
autocomplete: { [key: string]: any };
}
/**
* Check if a given object implements the PaginatedAccountLockdownStageList interface.
*/
export function instanceOfPaginatedAccountLockdownStageList(
value: object,
): value is PaginatedAccountLockdownStageList {
if (!("pagination" in value) || value["pagination"] === undefined) return false;
if (!("results" in value) || value["results"] === undefined) return false;
if (!("autocomplete" in value) || value["autocomplete"] === undefined) return false;
return true;
}
export function PaginatedAccountLockdownStageListFromJSON(
json: any,
): PaginatedAccountLockdownStageList {
return PaginatedAccountLockdownStageListFromJSONTyped(json, false);
}
export function PaginatedAccountLockdownStageListFromJSONTyped(
json: any,
ignoreDiscriminator: boolean,
): PaginatedAccountLockdownStageList {
if (json == null) {
return json;
}
return {
pagination: PaginationFromJSON(json["pagination"]),
results: (json["results"] as Array<any>).map(AccountLockdownStageFromJSON),
autocomplete: json["autocomplete"],
};
}
export function PaginatedAccountLockdownStageListToJSON(
json: any,
): PaginatedAccountLockdownStageList {
return PaginatedAccountLockdownStageListToJSONTyped(json, false);
}
export function PaginatedAccountLockdownStageListToJSONTyped(
value?: PaginatedAccountLockdownStageList | null,
ignoreDiscriminator: boolean = false,
): any {
if (value == null) {
return value;
}
return {
pagination: PaginationToJSON(value["pagination"]),
results: (value["results"] as Array<any>).map(AccountLockdownStageToJSON),
autocomplete: value["autocomplete"],
};
}

View File

@@ -0,0 +1,117 @@
/* 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.
*/
/**
* AccountLockdownStage Serializer
* @export
* @interface PatchedAccountLockdownStageRequest
*/
export interface PatchedAccountLockdownStageRequest {
/**
*
* @type {string}
* @memberof PatchedAccountLockdownStageRequest
*/
name?: string;
/**
* Deactivate the user account (set is_active to False)
* @type {boolean}
* @memberof PatchedAccountLockdownStageRequest
*/
deactivateUser?: boolean;
/**
* Set an unusable password for the user
* @type {boolean}
* @memberof PatchedAccountLockdownStageRequest
*/
setUnusablePassword?: boolean;
/**
* Delete all active sessions for the user
* @type {boolean}
* @memberof PatchedAccountLockdownStageRequest
*/
deleteSessions?: boolean;
/**
* Revoke all tokens for the user (API, app password, recovery, verification, OAuth)
* @type {boolean}
* @memberof PatchedAccountLockdownStageRequest
*/
revokeTokens?: boolean;
/**
* Flow to redirect users to after self-service lockdown. This flow should not require authentication since the user's session is deleted.
* @type {string}
* @memberof PatchedAccountLockdownStageRequest
*/
selfServiceCompletionFlow?: string | null;
}
/**
* Check if a given object implements the PatchedAccountLockdownStageRequest interface.
*/
export function instanceOfPatchedAccountLockdownStageRequest(
value: object,
): value is PatchedAccountLockdownStageRequest {
return true;
}
export function PatchedAccountLockdownStageRequestFromJSON(
json: any,
): PatchedAccountLockdownStageRequest {
return PatchedAccountLockdownStageRequestFromJSONTyped(json, false);
}
export function PatchedAccountLockdownStageRequestFromJSONTyped(
json: any,
ignoreDiscriminator: boolean,
): PatchedAccountLockdownStageRequest {
if (json == null) {
return json;
}
return {
name: json["name"] == null ? undefined : json["name"],
deactivateUser: json["deactivate_user"] == null ? undefined : json["deactivate_user"],
setUnusablePassword:
json["set_unusable_password"] == null ? undefined : json["set_unusable_password"],
deleteSessions: json["delete_sessions"] == null ? undefined : json["delete_sessions"],
revokeTokens: json["revoke_tokens"] == null ? undefined : json["revoke_tokens"],
selfServiceCompletionFlow:
json["self_service_completion_flow"] == null
? undefined
: json["self_service_completion_flow"],
};
}
export function PatchedAccountLockdownStageRequestToJSON(
json: any,
): PatchedAccountLockdownStageRequest {
return PatchedAccountLockdownStageRequestToJSONTyped(json, false);
}
export function PatchedAccountLockdownStageRequestToJSONTyped(
value?: PatchedAccountLockdownStageRequest | null,
ignoreDiscriminator: boolean = false,
): any {
if (value == null) {
return value;
}
return {
name: value["name"],
deactivate_user: value["deactivateUser"],
set_unusable_password: value["setUnusablePassword"],
delete_sessions: value["deleteSessions"],
revoke_tokens: value["revokeTokens"],
self_service_completion_flow: value["selfServiceCompletionFlow"],
};
}

View File

@@ -96,6 +96,12 @@ export interface PatchedBrandRequest {
* @memberof PatchedBrandRequest
*/
flowDeviceCode?: string | null;
/**
*
* @type {string}
* @memberof PatchedBrandRequest
*/
flowLockdown?: string | null;
/**
* When set, external users will be redirected to this application after authenticating.
* @type {string}
@@ -160,6 +166,7 @@ export function PatchedBrandRequestFromJSONTyped(
flowUserSettings:
json["flow_user_settings"] == null ? undefined : json["flow_user_settings"],
flowDeviceCode: json["flow_device_code"] == null ? undefined : json["flow_device_code"],
flowLockdown: json["flow_lockdown"] == null ? undefined : json["flow_lockdown"],
defaultApplication:
json["default_application"] == null ? undefined : json["default_application"],
webCertificate: json["web_certificate"] == null ? undefined : json["web_certificate"],
@@ -195,6 +202,7 @@ export function PatchedBrandRequestToJSONTyped(
flow_unenrollment: value["flowUnenrollment"],
flow_user_settings: value["flowUserSettings"],
flow_device_code: value["flowDeviceCode"],
flow_lockdown: value["flowLockdown"],
default_application: value["defaultApplication"],
web_certificate: value["webCertificate"],
client_certificates: value["clientCertificates"],

View File

@@ -34,6 +34,9 @@ export const PromptTypeEnum = {
Separator: "separator",
Hidden: "hidden",
Static: "static",
AlertInfo: "alert_info",
AlertWarning: "alert_warning",
AlertDanger: "alert_danger",
AkLocale: "ak-locale",
UnknownDefaultOpenApi: "11184809",
} as const;

View File

@@ -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<ReviewerGroup>}
* @memberof RelatedRule
*/
readonly reviewerGroups: Array<ReviewerGroup>;
/**
*
* @type {number}
* @memberof RelatedRule
*/
readonly minReviewers: number;
/**
*
* @type {Array<ReviewerUser>}
* @memberof RelatedRule
*/
readonly reviewers: Array<ReviewerUser>;
}
/**
* 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<any>).map(ReviewerGroupFromJSON),
minReviewers: json["min_reviewers"],
reviewers: (json["reviewers"] as Array<any>).map(ReviewerUserFromJSON),
};
}
export function RelatedRuleToJSON(json: any): RelatedRule {
return RelatedRuleToJSONTyped(json, false);
}
export function RelatedRuleToJSONTyped(
value?: Omit<RelatedRule, "reviewer_groups" | "min_reviewers" | "reviewers"> | null,
ignoreDiscriminator: boolean = false,
): any {
if (value == null) {
return value;
}
return {
id: value["id"],
name: value["name"],
};
}

View File

@@ -0,0 +1,69 @@
/* 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.
*/
/**
* Choose the target account before starting the lockdown flow.
* @export
* @interface UserAccountLockdownRequest
*/
export interface UserAccountLockdownRequest {
/**
* User to lock. If omitted, locks the current user (self-service).
* @type {number}
* @memberof UserAccountLockdownRequest
*/
user?: number | null;
}
/**
* Check if a given object implements the UserAccountLockdownRequest interface.
*/
export function instanceOfUserAccountLockdownRequest(
value: object,
): value is UserAccountLockdownRequest {
return true;
}
export function UserAccountLockdownRequestFromJSON(json: any): UserAccountLockdownRequest {
return UserAccountLockdownRequestFromJSONTyped(json, false);
}
export function UserAccountLockdownRequestFromJSONTyped(
json: any,
ignoreDiscriminator: boolean,
): UserAccountLockdownRequest {
if (json == null) {
return json;
}
return {
user: json["user"] == null ? undefined : json["user"],
};
}
export function UserAccountLockdownRequestToJSON(json: any): UserAccountLockdownRequest {
return UserAccountLockdownRequestToJSONTyped(json, false);
}
export function UserAccountLockdownRequestToJSONTyped(
value?: UserAccountLockdownRequest | null,
ignoreDiscriminator: boolean = false,
): any {
if (value == null) {
return value;
}
return {
user: value["user"],
};
}

View File

@@ -1,6 +1,8 @@
/* tslint:disable */
/* eslint-disable */
export * from "./AccessDeniedChallenge";
export * from "./AccountLockdownStage";
export * from "./AccountLockdownStageRequest";
export * from "./AgentAuthenticationResponse";
export * from "./AgentConfig";
export * from "./AgentConnector";
@@ -352,6 +354,7 @@ export * from "./OutpostHealth";
export * from "./OutpostRequest";
export * from "./OutpostTypeEnum";
export * from "./PKCEMethodEnum";
export * from "./PaginatedAccountLockdownStageList";
export * from "./PaginatedAgentConnectorList";
export * from "./PaginatedAppleIndependentSecureEnclaveList";
export * from "./PaginatedApplicationEntitlementList";
@@ -518,6 +521,7 @@ export * from "./PasswordPolicy";
export * from "./PasswordPolicyRequest";
export * from "./PasswordStage";
export * from "./PasswordStageRequest";
export * from "./PatchedAccountLockdownStageRequest";
export * from "./PatchedAgentConnectorRequest";
export * from "./PatchedAppleIndependentSecureEnclaveRequest";
export * from "./PatchedApplicationEntitlementRequest";
@@ -705,6 +709,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";
@@ -821,6 +826,7 @@ export * from "./UsageEnum";
export * from "./UsedBy";
export * from "./UsedByActionEnum";
export * from "./User";
export * from "./UserAccountLockdownRequest";
export * from "./UserAccountRequest";
export * from "./UserAccountSerializerForRoleRequest";
export * from "./UserAttributeEnum";

View File

@@ -3172,6 +3172,11 @@ paths:
schema:
type: string
format: uuid
- in: query
name: flow_lockdown
schema:
type: string
format: uuid
- in: query
name: flow_recovery
schema:
@@ -4585,6 +4590,35 @@ paths:
$ref: '#/components/responses/ValidationErrorResponse'
'403':
$ref: '#/components/responses/GenericErrorResponse'
/core/users/account_lockdown/:
post:
operationId: core_users_account_lockdown_create
description: Choose the target account, then return a flow link.
tags:
- core
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/UserAccountLockdownRequest'
security:
- authentik: []
responses:
'200':
content:
application/json:
schema:
$ref: '#/components/schemas/Link'
examples:
LockdownFlowURL:
value:
link: https://example.invalid/if/flow/default-account-lockdown/
summary: Lockdown flow URL
description: ''
'400':
description: No lockdown flow configured or the flow is not applicable
'403':
description: Permission denied (when targeting another user)
/core/users/export/:
post:
operationId: core_users_export_create
@@ -9132,7 +9166,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 +9183,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 +9198,9 @@ paths:
content:
application/json:
schema:
$ref: '#/components/schemas/LifecycleIteration'
type: array
items:
$ref: '#/components/schemas/LifecycleIteration'
description: ''
'400':
$ref: '#/components/responses/ValidationErrorResponse'
@@ -26893,6 +26935,222 @@ paths:
$ref: '#/components/responses/ValidationErrorResponse'
'403':
$ref: '#/components/responses/GenericErrorResponse'
/stages/account_lockdown/:
get:
operationId: stages_account_lockdown_list
description: AccountLockdownStage Viewset
parameters:
- in: query
name: deactivate_user
schema:
type: boolean
- in: query
name: delete_sessions
schema:
type: boolean
- $ref: '#/components/parameters/QueryName'
- $ref: '#/components/parameters/QueryPaginationOrdering'
- $ref: '#/components/parameters/QueryPaginationPage'
- $ref: '#/components/parameters/QueryPaginationPageSize'
- in: query
name: revoke_tokens
schema:
type: boolean
- $ref: '#/components/parameters/QuerySearch'
- in: query
name: self_service_completion_flow
schema:
type: string
format: uuid
- in: query
name: set_unusable_password
schema:
type: boolean
- in: query
name: stage_uuid
schema:
type: string
format: uuid
tags:
- stages
security:
- authentik: []
responses:
'200':
content:
application/json:
schema:
$ref: '#/components/schemas/PaginatedAccountLockdownStageList'
description: ''
'400':
$ref: '#/components/responses/ValidationErrorResponse'
'403':
$ref: '#/components/responses/GenericErrorResponse'
post:
operationId: stages_account_lockdown_create
description: AccountLockdownStage Viewset
tags:
- stages
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/AccountLockdownStageRequest'
required: true
security:
- authentik: []
responses:
'201':
content:
application/json:
schema:
$ref: '#/components/schemas/AccountLockdownStage'
description: ''
'400':
$ref: '#/components/responses/ValidationErrorResponse'
'403':
$ref: '#/components/responses/GenericErrorResponse'
/stages/account_lockdown/{stage_uuid}/:
get:
operationId: stages_account_lockdown_retrieve
description: AccountLockdownStage Viewset
parameters:
- in: path
name: stage_uuid
schema:
type: string
format: uuid
description: A UUID string identifying this Account Lockdown Stage.
required: true
tags:
- stages
security:
- authentik: []
responses:
'200':
content:
application/json:
schema:
$ref: '#/components/schemas/AccountLockdownStage'
description: ''
'400':
$ref: '#/components/responses/ValidationErrorResponse'
'403':
$ref: '#/components/responses/GenericErrorResponse'
put:
operationId: stages_account_lockdown_update
description: AccountLockdownStage Viewset
parameters:
- in: path
name: stage_uuid
schema:
type: string
format: uuid
description: A UUID string identifying this Account Lockdown Stage.
required: true
tags:
- stages
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/AccountLockdownStageRequest'
required: true
security:
- authentik: []
responses:
'200':
content:
application/json:
schema:
$ref: '#/components/schemas/AccountLockdownStage'
description: ''
'400':
$ref: '#/components/responses/ValidationErrorResponse'
'403':
$ref: '#/components/responses/GenericErrorResponse'
patch:
operationId: stages_account_lockdown_partial_update
description: AccountLockdownStage Viewset
parameters:
- in: path
name: stage_uuid
schema:
type: string
format: uuid
description: A UUID string identifying this Account Lockdown Stage.
required: true
tags:
- stages
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/PatchedAccountLockdownStageRequest'
security:
- authentik: []
responses:
'200':
content:
application/json:
schema:
$ref: '#/components/schemas/AccountLockdownStage'
description: ''
'400':
$ref: '#/components/responses/ValidationErrorResponse'
'403':
$ref: '#/components/responses/GenericErrorResponse'
delete:
operationId: stages_account_lockdown_destroy
description: AccountLockdownStage Viewset
parameters:
- in: path
name: stage_uuid
schema:
type: string
format: uuid
description: A UUID string identifying this Account Lockdown Stage.
required: true
tags:
- stages
security:
- authentik: []
responses:
'204':
description: No response body
'400':
$ref: '#/components/responses/ValidationErrorResponse'
'403':
$ref: '#/components/responses/GenericErrorResponse'
/stages/account_lockdown/{stage_uuid}/used_by/:
get:
operationId: stages_account_lockdown_used_by_list
description: Get a list of all objects that use this object
parameters:
- in: path
name: stage_uuid
schema:
type: string
format: uuid
description: A UUID string identifying this Account Lockdown Stage.
required: true
tags:
- stages
security:
- authentik: []
responses:
'200':
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/UsedBy'
description: ''
'400':
$ref: '#/components/responses/ValidationErrorResponse'
'403':
$ref: '#/components/responses/GenericErrorResponse'
/stages/all/:
get:
operationId: stages_all_list
@@ -33661,6 +33919,93 @@ components:
required:
- pending_user
- pending_user_avatar
AccountLockdownStage:
type: object
description: AccountLockdownStage Serializer
properties:
pk:
type: string
format: uuid
readOnly: true
title: Stage uuid
name:
type: string
component:
type: string
description: Get object type so that we know how to edit the object
readOnly: true
verbose_name:
type: string
description: Return object's verbose_name
readOnly: true
verbose_name_plural:
type: string
description: Return object's plural verbose_name
readOnly: true
meta_model_name:
type: string
description: Return internal model name
readOnly: true
flow_set:
type: array
items:
$ref: '#/components/schemas/FlowSet'
readOnly: true
deactivate_user:
type: boolean
description: Deactivate the user account (set is_active to False)
set_unusable_password:
type: boolean
description: Set an unusable password for the user
delete_sessions:
type: boolean
description: Delete all active sessions for the user
revoke_tokens:
type: boolean
description: Revoke all tokens for the user (API, app password, recovery,
verification, OAuth)
self_service_completion_flow:
type: string
format: uuid
nullable: true
description: Flow to redirect users to after self-service lockdown. This
flow should not require authentication since the user's session is deleted.
required:
- component
- flow_set
- meta_model_name
- name
- pk
- verbose_name
- verbose_name_plural
AccountLockdownStageRequest:
type: object
description: AccountLockdownStage Serializer
properties:
name:
type: string
minLength: 1
deactivate_user:
type: boolean
description: Deactivate the user account (set is_active to False)
set_unusable_password:
type: boolean
description: Set an unusable password for the user
delete_sessions:
type: boolean
description: Delete all active sessions for the user
revoke_tokens:
type: boolean
description: Revoke all tokens for the user (API, app password, recovery,
verification, OAuth)
self_service_completion_flow:
type: string
format: uuid
nullable: true
description: Flow to redirect users to after self-service lockdown. This
flow should not require authentication since the user's session is deleted.
required:
- name
AgentAuthenticationResponse:
type: object
description: Base serializer class which doesn't implement create/update methods
@@ -33998,6 +34343,7 @@ components:
- authentik.enterprise.providers.ssf
- authentik.enterprise.providers.ws_federation
- authentik.enterprise.reports
- authentik.enterprise.stages.account_lockdown
- authentik.enterprise.stages.authenticator_endpoint_gdtc
- authentik.enterprise.stages.mtls
- authentik.enterprise.stages.source
@@ -35746,6 +36092,10 @@ components:
type: string
format: uuid
nullable: true
flow_lockdown:
type: string
format: uuid
nullable: true
default_application:
type: string
format: uuid
@@ -35818,6 +36168,10 @@ components:
type: string
format: uuid
nullable: true
flow_lockdown:
type: string
format: uuid
nullable: true
default_application:
type: string
format: uuid
@@ -36806,6 +37160,8 @@ components:
type: string
flow_device_code:
type: string
flow_lockdown:
type: string
default_locale:
type: string
readOnly: true
@@ -42426,35 +42782,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:
@@ -43145,6 +43490,7 @@ components:
- authentik_providers_ssf.ssfprovider
- authentik_providers_ws_federation.wsfederationprovider
- authentik_reports.dataexport
- authentik_stages_account_lockdown.accountlockdownstage
- authentik_stages_authenticator_endpoint_gdtc.authenticatorendpointgdtcstage
- authentik_stages_mtls.mutualtlsstage
- authentik_stages_source.sourcestage
@@ -44587,6 +44933,21 @@ components:
- plain
- S256
type: string
PaginatedAccountLockdownStageList:
type: object
properties:
pagination:
$ref: '#/components/schemas/Pagination'
results:
type: array
items:
$ref: '#/components/schemas/AccountLockdownStage'
autocomplete:
$ref: '#/components/schemas/Autocomplete'
required:
- autocomplete
- pagination
- results
PaginatedAgentConnectorList:
type: object
properties:
@@ -47355,6 +47716,32 @@ components:
required:
- backends
- name
PatchedAccountLockdownStageRequest:
type: object
description: AccountLockdownStage Serializer
properties:
name:
type: string
minLength: 1
deactivate_user:
type: boolean
description: Deactivate the user account (set is_active to False)
set_unusable_password:
type: boolean
description: Set an unusable password for the user
delete_sessions:
type: boolean
description: Delete all active sessions for the user
revoke_tokens:
type: boolean
description: Revoke all tokens for the user (API, app password, recovery,
verification, OAuth)
self_service_completion_flow:
type: string
format: uuid
nullable: true
description: Flow to redirect users to after self-service lockdown. This
flow should not require authentication since the user's session is deleted.
PatchedAgentConnectorRequest:
type: object
properties:
@@ -47798,6 +48185,10 @@ components:
type: string
format: uuid
nullable: true
flow_lockdown:
type: string
format: uuid
nullable: true
default_application:
type: string
format: uuid
@@ -52084,6 +52475,9 @@ components:
- separator
- hidden
- static
- alert_info
- alert_warning
- alert_danger
- ak-locale
type: string
PropertyMapping:
@@ -53285,6 +53679,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
@@ -57153,6 +57576,14 @@ components:
- uid
- username
- uuid
UserAccountLockdownRequest:
type: object
description: Choose the target account before starting the lockdown flow.
properties:
user:
type: integer
nullable: true
description: User to lock. If omitted, locks the current user (self-service).
UserAccountRequest:
type: object
description: Account adding/removing operations

194
web/package-lock.json generated
View File

@@ -78,7 +78,7 @@
"globals": "^17.5.0",
"guacamole-common-js": "^1.5.0",
"hastscript": "^9.0.1",
"knip": "^6.6.0",
"knip": "^6.6.3",
"lex": "^2025.11.0",
"lit": "^3.3.2",
"lit-analyzer": "^2.0.3",
@@ -2224,9 +2224,9 @@
}
},
"node_modules/@oxc-parser/binding-android-arm-eabi": {
"version": "0.126.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm-eabi/-/binding-android-arm-eabi-0.126.0.tgz",
"integrity": "sha512-svyoHt25J4741QJ5aa4R+h0iiBeSRt63Lr3aAZcxy2c/NeSE1IfDeMnSij6rIg7EjxkdlXzz613wUjeCeilBNA==",
"version": "0.127.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm-eabi/-/binding-android-arm-eabi-0.127.0.tgz",
"integrity": "sha512-0LC7ye4hvqbIKxAzThzvswgHLFu2AURKzYLeSVvLdu2TBOYWQDmHnTqPLeA597BcUCxiLqLsS4CJ5uoI5WYWCQ==",
"cpu": [
"arm"
],
@@ -2240,9 +2240,9 @@
}
},
"node_modules/@oxc-parser/binding-android-arm64": {
"version": "0.126.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm64/-/binding-android-arm64-0.126.0.tgz",
"integrity": "sha512-hPEBRKgplp1mG9GkINFsr4JVMDNrGJLOqfDaadTWpAoTnzYR5Rmv8RMvB3hJZpiNvbk1aacopdHUP1pggMQ/cw==",
"version": "0.127.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm64/-/binding-android-arm64-0.127.0.tgz",
"integrity": "sha512-b5jtVTH6AU5CJXHNdj7Jj9IEiR9yVjjnwHzPJhGyHGPdcsZSzBCkS9GBbV33niRMvKthDwQRFRJfI4a+k4PvYg==",
"cpu": [
"arm64"
],
@@ -2256,9 +2256,9 @@
}
},
"node_modules/@oxc-parser/binding-darwin-arm64": {
"version": "0.126.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-darwin-arm64/-/binding-darwin-arm64-0.126.0.tgz",
"integrity": "sha512-ccRpu9sdYmznePJQG5halhs0FW5tw5a8zRSoZXOzM1OjoeZ4jiRRruFiPclsD59edoVAK1l83dvfjWz1nQi6lg==",
"version": "0.127.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-darwin-arm64/-/binding-darwin-arm64-0.127.0.tgz",
"integrity": "sha512-obCE8B7ISKkJidjlhv9xRGJPOSDG2Yu6PRga9Ruaz35uintHxbp1Ki/Yc71wx4rj3Edrm0a1kzG1TAwit0wFpg==",
"cpu": [
"arm64"
],
@@ -2272,9 +2272,9 @@
}
},
"node_modules/@oxc-parser/binding-darwin-x64": {
"version": "0.126.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-darwin-x64/-/binding-darwin-x64-0.126.0.tgz",
"integrity": "sha512-CHB4zVjNSKqx8Fw9pHowzQQnjjuq04i4Ng0Avj+DixlwhwAoMYqlFbocYIlbg+q3zOLGlm7vEHm83jqEMitnyg==",
"version": "0.127.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-darwin-x64/-/binding-darwin-x64-0.127.0.tgz",
"integrity": "sha512-JL6Xb5IwPQT8rUzlpsX7E+AgfcdNklXNPFp8pjCQQ5MQOQo5rtEB2ui+3Hgg9Sn7Y9Egj6YOLLiHhLpdAe12Aw==",
"cpu": [
"x64"
],
@@ -2288,9 +2288,9 @@
}
},
"node_modules/@oxc-parser/binding-freebsd-x64": {
"version": "0.126.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-freebsd-x64/-/binding-freebsd-x64-0.126.0.tgz",
"integrity": "sha512-RQ3nEJdcDKBfBjmLJ3Vl1d0KQERPV1P8eUrnBm7+VTYyoaJSPLVFuPg1mlD1hk3n0/879VLFMfusFkBal4ssWQ==",
"version": "0.127.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-freebsd-x64/-/binding-freebsd-x64-0.127.0.tgz",
"integrity": "sha512-SDQ/3MQFw58fqQz3Z1PhSKFF3JoCF4gmlNjziDm8X02tTahCw0qJbd7FGPDKw1i4VTBZene9JPyC3mHtSvi+wA==",
"cpu": [
"x64"
],
@@ -2304,9 +2304,9 @@
}
},
"node_modules/@oxc-parser/binding-linux-arm-gnueabihf": {
"version": "0.126.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.126.0.tgz",
"integrity": "sha512-onipc2wCDA7Bauzb4KK1mab0GsEDf4ujiIfWECdnmY/2LlzAoX3xdQRLAUyEDB1kn3yilHBrkmXDdHluyHXxiw==",
"version": "0.127.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.127.0.tgz",
"integrity": "sha512-Av+D1MIqzV0YMGPT9we2SIZaMKD7Cxs4CvXSx/yxaWHewZjYEjScpOf5igc8IILASViw4WTnjlwUdI1KzVtDHQ==",
"cpu": [
"arm"
],
@@ -2320,9 +2320,9 @@
}
},
"node_modules/@oxc-parser/binding-linux-arm-musleabihf": {
"version": "0.126.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.126.0.tgz",
"integrity": "sha512-5BuJJPohrV5NJ8lmcYOMbfRCUGoYH5J9HZHeuqOLwkHXWAuPMN3X1h8bC/2mWjmosdbfTtmyIdX3spS/TkqKNg==",
"version": "0.127.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.127.0.tgz",
"integrity": "sha512-Cs2fdJ8cPpFdeebj6p4dag8A4+56hPvZ0AhQQzlaLswGz1tz7bXt1nETLeorrM9+AMcWFFkqxcXwDGfTVidY8g==",
"cpu": [
"arm"
],
@@ -2336,9 +2336,9 @@
}
},
"node_modules/@oxc-parser/binding-linux-arm64-gnu": {
"version": "0.126.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.126.0.tgz",
"integrity": "sha512-r2KApRgm2pOJaduRm6GOT8x0whcr67AyejNkSdzPt34GJ+Y3axcXN2mwlTs+8lfO/SSmpO5ZJGYiHYnxEE0jkw==",
"version": "0.127.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.127.0.tgz",
"integrity": "sha512-qdOfTcT6SY8gsJrrV92uyEUyjqMGPpIB5JZUG6QN5dukYd+7/j0kX6MwK1DgQj39jtUYixxPiaRUiEN1+0CXgQ==",
"cpu": [
"arm64"
],
@@ -2352,9 +2352,9 @@
}
},
"node_modules/@oxc-parser/binding-linux-arm64-musl": {
"version": "0.126.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.126.0.tgz",
"integrity": "sha512-FQ+MMh7MT0Dr/u8+RWmWKlfoeWPQyHDbhhxJShJlYtROXXPHsRs9EvmQOZZ3sx4Nn7JU8NX+oyw2YzQ7anBJcA==",
"version": "0.127.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.127.0.tgz",
"integrity": "sha512-EoTCZneNFU/P2qrpEM+RHmQwt+CvDkyGESG6qhr7KaegXLZwePfbrkCDfAk8/rhxbDUVGsZILX+2tqPzFtoFWA==",
"cpu": [
"arm64"
],
@@ -2368,9 +2368,9 @@
}
},
"node_modules/@oxc-parser/binding-linux-ppc64-gnu": {
"version": "0.126.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.126.0.tgz",
"integrity": "sha512-Wv/T8C98hRQhGTlx2XFyLn5raRMp9U1lOQD+YnXNgAr7wHbJJpZ8mDBU7Rw+M3WytGcGTFcr6kqgfyQeHVtLbQ==",
"version": "0.127.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.127.0.tgz",
"integrity": "sha512-zALjmZYgxFLHjXeudcDF0xFGNydTAtkAeXAr2EuC17ywCyFxcmQra4w0BMde0Yi/re4Bi4iwEoEXtYN7l6eBLQ==",
"cpu": [
"ppc64"
],
@@ -2384,9 +2384,9 @@
}
},
"node_modules/@oxc-parser/binding-linux-riscv64-gnu": {
"version": "0.126.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.126.0.tgz",
"integrity": "sha512-DHx1rT1zauW0ZbLHOiQh5AC9Xs3UkWx2XmfZHs+7nnWYr3sagrufoUQC+/XPwwjMIlCFXiFGM0sFh3TyOCZwqA==",
"version": "0.127.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.127.0.tgz",
"integrity": "sha512-fPP8M6zQLS7Jz7o9d5ArUSuAuSK3e+WCYVrCpdzeCOejidtZExJ9tjhDrAd3HEPqARBCPmdpqxESPFqy44vkBQ==",
"cpu": [
"riscv64"
],
@@ -2400,9 +2400,9 @@
}
},
"node_modules/@oxc-parser/binding-linux-riscv64-musl": {
"version": "0.126.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.126.0.tgz",
"integrity": "sha512-umDc2mTShH0U2zcEYf8mIJ163seLJNn54ZUZYeI5jD4qlg9izPwoLrC2aNPKlMJTu6u/ysmQWiEvIiaAG+INkw==",
"version": "0.127.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.127.0.tgz",
"integrity": "sha512-7IcC4Ao02oGpfnjt+X/oF4U2mllo2qoSkw5xxiXNKL9MCTsTiAC6616beOuehdxGcnz1bRoPC1RQ2f1GQDdN+g==",
"cpu": [
"riscv64"
],
@@ -2416,9 +2416,9 @@
}
},
"node_modules/@oxc-parser/binding-linux-s390x-gnu": {
"version": "0.126.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.126.0.tgz",
"integrity": "sha512-PXXeWayclRtO1pxQEeCpiqIglQdhK2mAI2VX5xnsWdImzSB5GpoQ8TNw7vTCKk2k+GZuxl+q1knncidjCyUP9w==",
"version": "0.127.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.127.0.tgz",
"integrity": "sha512-pbXIhiNFHoqWeqDNLiJ9JkpHz1IM9k4DXa66x+1GTWMG7iLxtkXgE53iiuKSXwmk3zIYmaPVfBvgcAhS583K4Q==",
"cpu": [
"s390x"
],
@@ -2432,9 +2432,9 @@
}
},
"node_modules/@oxc-parser/binding-linux-x64-gnu": {
"version": "0.126.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.126.0.tgz",
"integrity": "sha512-wzocjxm34TbB3bFlqG65JiLtvf6ZDg2ZxRkLLbgXwDQUNU+0MPjQN8zy/0jBKNA5fnPLk3XeVdZ7Uin+7+CVkg==",
"version": "0.127.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.127.0.tgz",
"integrity": "sha512-MYCguB9RvBvlSd6gbuNI7QwiLoCCAlGnlRJFPrzLI6U1/9wkC/WK6LtBAUln55H1Ctqw45PWmqrobKoMhsYQzQ==",
"cpu": [
"x64"
],
@@ -2448,9 +2448,9 @@
}
},
"node_modules/@oxc-parser/binding-linux-x64-musl": {
"version": "0.126.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-musl/-/binding-linux-x64-musl-0.126.0.tgz",
"integrity": "sha512-e83uftP60jmkPs2+CW6T6A1GYzN2H6IumDAiTntv9WyHR73PI3ImHNBkYqnA3ukeKI3xjcCbhSh9QeJWmufxGQ==",
"version": "0.127.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-musl/-/binding-linux-x64-musl-0.127.0.tgz",
"integrity": "sha512-5eY0B/bxf1xIUxb4NOTvOI3KWtBQfPWYyKAzgcrCt0mDibSZygVpO1Pz8bkeiSZ5Jj9+M09dkggG3H8I5d0Uyg==",
"cpu": [
"x64"
],
@@ -2464,9 +2464,9 @@
}
},
"node_modules/@oxc-parser/binding-openharmony-arm64": {
"version": "0.126.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-openharmony-arm64/-/binding-openharmony-arm64-0.126.0.tgz",
"integrity": "sha512-4WiOILHnPrTDY2/L4mE6PZCYwLN1d3ghma6BuTJ452CCgzRMt3uFplCtR+o3r9zdUWJYb370UizpI9CUcWXr1A==",
"version": "0.127.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-openharmony-arm64/-/binding-openharmony-arm64-0.127.0.tgz",
"integrity": "sha512-Gld0ajrFTUXNtdw20fVBuTQx66FA75nIVg+//pPfR3sXkuABB4mTBhl3r9JNzrJpgW//qiwxf0nWXUWGJSL3UQ==",
"cpu": [
"arm64"
],
@@ -2480,9 +2480,9 @@
}
},
"node_modules/@oxc-parser/binding-wasm32-wasi": {
"version": "0.126.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-wasm32-wasi/-/binding-wasm32-wasi-0.126.0.tgz",
"integrity": "sha512-Y17hhnrQTrxgAxAyAq401vnN9URsAL4s5AjqpG1NDsXSlhe1yBNnns+rC2P6xcMoitgX5nKH2ryYt9oiFRlzLw==",
"version": "0.127.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-wasm32-wasi/-/binding-wasm32-wasi-0.127.0.tgz",
"integrity": "sha512-T6KVD7rhLzFlwGRXMnxUFfkCZD8FHnb968wVXW1mXzgRFc5RNXOBY2mPPDZ77x5Ln76ltLMgtPg0cOkU1NSrEQ==",
"cpu": [
"wasm32"
],
@@ -2494,13 +2494,13 @@
"@napi-rs/wasm-runtime": "^1.1.4"
},
"engines": {
"node": ">=14.0.0"
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@oxc-parser/binding-win32-arm64-msvc": {
"version": "0.126.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.126.0.tgz",
"integrity": "sha512-Znug1u1iRvT4VC3jANz6nhGBHsFwEFMxuimYpJFwMtsB6H5FcEoZRMmH26tHkSTD03JvDmG+gB65W3ajLjPcSw==",
"version": "0.127.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.127.0.tgz",
"integrity": "sha512-Ujvw4X+LD1CCGULcsQcvb4YNVoBGqt+JHgNNzGGaCImELiZLk477ifUH53gIbE7EKd933NdTi25JWEr9K2HwXw==",
"cpu": [
"arm64"
],
@@ -2514,9 +2514,9 @@
}
},
"node_modules/@oxc-parser/binding-win32-ia32-msvc": {
"version": "0.126.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.126.0.tgz",
"integrity": "sha512-qrw7mx5hFFTxVSXToOA40hpnjgNB/DJprZchtB4rDKNLKqkD3F26HbzaQeH1nxAKej0efSZfJd5Sw3qdtOLGhw==",
"version": "0.127.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.127.0.tgz",
"integrity": "sha512-0cwxKO7KHQQQfo4Uf4B2SQrhgm+cJaP9OvFFhx52Tkg4bezsacu83GB2/In5bC415Ueeym+kXdnge/57rbSfTw==",
"cpu": [
"ia32"
],
@@ -2530,9 +2530,9 @@
}
},
"node_modules/@oxc-parser/binding-win32-x64-msvc": {
"version": "0.126.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.126.0.tgz",
"integrity": "sha512-ibB1s+mPUFXvS7MFJO2jpw/aCNs/P6ifnWlRyTYB+WYBpniOiCcHQQskZneJtwcjQMDRol3RGG3ihoYnzXSY4w==",
"version": "0.127.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.127.0.tgz",
"integrity": "sha512-rOrnSQSCbhI2kowr9XxE7m9a8oQXnBHjnS6j95LxxAnEZ0+Fz20WlRXG4ondQb+ejjt2KOsa65sE6++L6kUd+w==",
"cpu": [
"x64"
],
@@ -2546,9 +2546,9 @@
}
},
"node_modules/@oxc-project/types": {
"version": "0.126.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.126.0.tgz",
"integrity": "sha512-oGfVtjAgwQVVpfBrbtk4e1XDyWHRFta6BS3GWVzrF8xYBT2VGQAk39yJS/wFSMrZqoiCU4oghT3Ch0HaHGIHcQ==",
"version": "0.127.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.127.0.tgz",
"integrity": "sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/Boshen"
@@ -11569,9 +11569,9 @@
}
},
"node_modules/knip": {
"version": "6.6.0",
"resolved": "https://registry.npmjs.org/knip/-/knip-6.6.0.tgz",
"integrity": "sha512-IT1YDiHyRctYYsuZNBd/ZiGoa7HmCaxs+ZrWxCfYjQKPG6QyRqMfkteqC+rBuMymBJeLXyBnRa7hn95O+sGG8Q==",
"version": "6.6.3",
"resolved": "https://registry.npmjs.org/knip/-/knip-6.6.3.tgz",
"integrity": "sha512-7HSf5bLx6r66+sjXwSvSiDEE9RjRzHuAkrEFLE6XXHqaPDY97tdzNvyRVF9DeusbiV72kStAFiNnhj72rxJNGQ==",
"funding": [
{
"type": "github",
@@ -11589,13 +11589,13 @@
"get-tsconfig": "4.14.0",
"jiti": "^2.6.0",
"minimist": "^1.2.8",
"oxc-parser": "^0.126.0",
"oxc-parser": "^0.127.0",
"oxc-resolver": "^11.19.1",
"picomatch": "^4.0.4",
"smol-toml": "^1.6.1",
"strip-json-comments": "5.0.3",
"tinyglobby": "^0.2.16",
"unbash": "^2.2.0",
"unbash": "^3.0.0",
"yaml": "^2.8.2",
"zod": "^4.1.11"
},
@@ -14283,12 +14283,12 @@
}
},
"node_modules/oxc-parser": {
"version": "0.126.0",
"resolved": "https://registry.npmjs.org/oxc-parser/-/oxc-parser-0.126.0.tgz",
"integrity": "sha512-FktCvLby/mOHyuijZt22+nOt10dS24gGUZE3XwIbUg7Kf4+rer3/5T7RgwzazlNuVsCjPloZ3p8E+4ONT3A8Kw==",
"version": "0.127.0",
"resolved": "https://registry.npmjs.org/oxc-parser/-/oxc-parser-0.127.0.tgz",
"integrity": "sha512-bkgD4qHlN7WxLdX8bLXdaU54TtQtAIg/ZBAfm0aje/mo3MRDo3P0hZSgr4U7O3xfX+fQmR5AP04JS/TGcZLcFA==",
"license": "MIT",
"dependencies": {
"@oxc-project/types": "^0.126.0"
"@oxc-project/types": "^0.127.0"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
@@ -14297,26 +14297,26 @@
"url": "https://github.com/sponsors/Boshen"
},
"optionalDependencies": {
"@oxc-parser/binding-android-arm-eabi": "0.126.0",
"@oxc-parser/binding-android-arm64": "0.126.0",
"@oxc-parser/binding-darwin-arm64": "0.126.0",
"@oxc-parser/binding-darwin-x64": "0.126.0",
"@oxc-parser/binding-freebsd-x64": "0.126.0",
"@oxc-parser/binding-linux-arm-gnueabihf": "0.126.0",
"@oxc-parser/binding-linux-arm-musleabihf": "0.126.0",
"@oxc-parser/binding-linux-arm64-gnu": "0.126.0",
"@oxc-parser/binding-linux-arm64-musl": "0.126.0",
"@oxc-parser/binding-linux-ppc64-gnu": "0.126.0",
"@oxc-parser/binding-linux-riscv64-gnu": "0.126.0",
"@oxc-parser/binding-linux-riscv64-musl": "0.126.0",
"@oxc-parser/binding-linux-s390x-gnu": "0.126.0",
"@oxc-parser/binding-linux-x64-gnu": "0.126.0",
"@oxc-parser/binding-linux-x64-musl": "0.126.0",
"@oxc-parser/binding-openharmony-arm64": "0.126.0",
"@oxc-parser/binding-wasm32-wasi": "0.126.0",
"@oxc-parser/binding-win32-arm64-msvc": "0.126.0",
"@oxc-parser/binding-win32-ia32-msvc": "0.126.0",
"@oxc-parser/binding-win32-x64-msvc": "0.126.0"
"@oxc-parser/binding-android-arm-eabi": "0.127.0",
"@oxc-parser/binding-android-arm64": "0.127.0",
"@oxc-parser/binding-darwin-arm64": "0.127.0",
"@oxc-parser/binding-darwin-x64": "0.127.0",
"@oxc-parser/binding-freebsd-x64": "0.127.0",
"@oxc-parser/binding-linux-arm-gnueabihf": "0.127.0",
"@oxc-parser/binding-linux-arm-musleabihf": "0.127.0",
"@oxc-parser/binding-linux-arm64-gnu": "0.127.0",
"@oxc-parser/binding-linux-arm64-musl": "0.127.0",
"@oxc-parser/binding-linux-ppc64-gnu": "0.127.0",
"@oxc-parser/binding-linux-riscv64-gnu": "0.127.0",
"@oxc-parser/binding-linux-riscv64-musl": "0.127.0",
"@oxc-parser/binding-linux-s390x-gnu": "0.127.0",
"@oxc-parser/binding-linux-x64-gnu": "0.127.0",
"@oxc-parser/binding-linux-x64-musl": "0.127.0",
"@oxc-parser/binding-openharmony-arm64": "0.127.0",
"@oxc-parser/binding-wasm32-wasi": "0.127.0",
"@oxc-parser/binding-win32-arm64-msvc": "0.127.0",
"@oxc-parser/binding-win32-ia32-msvc": "0.127.0",
"@oxc-parser/binding-win32-x64-msvc": "0.127.0"
}
},
"node_modules/oxc-resolver": {
@@ -18014,9 +18014,9 @@
}
},
"node_modules/unbash": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/unbash/-/unbash-2.2.0.tgz",
"integrity": "sha512-X2wH19RAPZE3+ldGicOkoj/SIA83OIxcJ6Cuaw23hf8Xc6fQpvZXY0SftE2JgS0QhYLUG4uwodSI3R53keyh7w==",
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/unbash/-/unbash-3.0.0.tgz",
"integrity": "sha512-FeFPZ/WFT0mbRCuydiZzpPFlrYN8ZUpphQKoq4EeElVIYjYyGzPMxQR/simUwCOJIyVhpFk4RbtyO7RuMpMnHA==",
"license": "ISC",
"engines": {
"node": ">=14"

View File

@@ -154,7 +154,7 @@
"globals": "^17.5.0",
"guacamole-common-js": "^1.5.0",
"hastscript": "^9.0.1",
"knip": "^6.6.0",
"knip": "^6.6.3",
"lex": "^2025.11.0",
"lit": "^3.3.2",
"lit-analyzer": "^2.0.3",

View File

@@ -1,6 +1,7 @@
import "#admin/common/ak-crypto-certificate-search";
import "#admin/common/ak-flow-search/ak-flow-search";
import "#elements/CodeMirror";
import "#elements/Alert";
import "#elements/ak-dual-select/ak-dual-select-dynamic-selected-provider";
import "#elements/ak-dual-select/ak-dual-select-provider";
import "#elements/forms/FormGroup";
@@ -25,42 +26,79 @@ import {
Brand,
CoreApi,
CoreApplicationsListRequest,
Flow,
FlowDesignationEnum,
FlowsApi,
UsageEnum,
} from "@goauthentik/api";
import { AuthenticationEnum } from "@goauthentik/api/dist/models/AuthenticationEnum.js";
import YAML from "yaml";
import { msg } from "@lit/localize";
import { html, TemplateResult } from "lit";
import { customElement } from "lit/decorators.js";
import { customElement, state } from "lit/decorators.js";
@customElement("ak-brand-form")
export class BrandForm extends ModelForm<Brand, string> {
public static override verboseName = msg("Brand");
public static override verboseNamePlural = msg("Brands");
loadInstance(pk: string): Promise<Brand> {
return new CoreApi(DEFAULT_CONFIG).coreBrandsRetrieve({
brandUuid: pk,
#coreAPI = new CoreApi(DEFAULT_CONFIG);
#flowsAPI = new FlowsApi(DEFAULT_CONFIG);
@state()
protected lockdownFlowAuthentication: AuthenticationEnum | null = null;
async loadInstance(pk: string): Promise<Brand> {
return this.#coreAPI.coreBrandsRetrieve({ brandUuid: pk }).then(async (brand) => {
if (!brand.flowLockdown) {
this.lockdownFlowAuthentication = null;
return brand;
}
return this.#flowsAPI
.flowsInstancesList({ flowUuid: brand.flowLockdown })
.then((flows) => {
this.lockdownFlowAuthentication = flows.results[0]?.authentication ?? null;
return brand;
});
});
}
getSuccessMessage(): string {
protected lockdownFlowInputListener = (event: Event): void => {
const target = event.currentTarget as HTMLElement & {
selectedFlow?: Flow | null;
};
this.lockdownFlowAuthentication = target.selectedFlow?.authentication ?? null;
};
protected get lockdownWarningVisible(): boolean {
return !!(
this.lockdownFlowAuthentication &&
this.lockdownFlowAuthentication !== AuthenticationEnum.RequireAuthenticated
);
}
public override getSuccessMessage(): string {
return this.instance
? msg("Successfully updated brand.")
: msg("Successfully created brand.");
}
async send(data: Brand): Promise<Brand> {
protected override async send(data: Brand): Promise<Brand> {
data.attributes ??= {};
if (this.instance?.brandUuid) {
return new CoreApi(DEFAULT_CONFIG).coreBrandsPartialUpdate({
return this.#coreAPI.coreBrandsPartialUpdate({
brandUuid: this.instance.brandUuid,
patchedBrandRequest: data,
});
}
return new CoreApi(DEFAULT_CONFIG).coreBrandsCreate({
return this.#coreAPI.coreBrandsCreate({
brandRequest: data,
});
}
@@ -285,6 +323,29 @@ export class BrandForm extends ModelForm<Brand, string> {
)}
</p>
</ak-form-element-horizontal>
<ak-form-element-horizontal
label=${msg("Account lockdown flow")}
name="flowLockdown"
>
<ak-flow-search
placeholder=${msg("Select an account lockdown flow...")}
flowType=${FlowDesignationEnum.StageConfiguration}
.currentFlow=${this.instance?.flowLockdown}
@input=${this.lockdownFlowInputListener}
></ak-flow-search>
<p class="pf-c-form__helper-text">
${msg(
"Flow used when a user triggers account lockdown (e.g. in case of compromise). Should contain an Account Lockdown stage.",
)}
</p>
${this.lockdownWarningVisible
? html`<ak-alert inline>
${msg(
"Account lockdown flows should require authentication so they can only be started from a signed-in session.",
)}
</ak-alert>`
: null}
</ak-form-element-horizontal>
</div>
</ak-form-group>
<ak-form-group label="${msg("Other global settings")} ">

View File

@@ -104,7 +104,7 @@ export class RuleForm extends ModelForm<NotificationRule, string> {
</p>
<p class="pf-c-form__helper-text">
${msg(
"If no group is selected and 'Send notification to event user' is disabled the rule is disabled. ",
"If no group is selected and 'Send notification to event user' is disabled, the rule is disabled.",
)}
</p>
</ak-form-element-horizontal>
@@ -113,7 +113,7 @@ export class RuleForm extends ModelForm<NotificationRule, string> {
label=${msg("Send notification to event user")}
?checked=${this.instance?.destinationEventUser ?? false}
help=${msg(
"When enabled, notification will be sent to the user that triggered the event in addition to any users in the group above. The event user will always be the first user, to send a notification only to the event user enabled 'Send once' in the notification transport. If no group is selected and 'Send notification to event user' is disabled the rule is disabled. ",
"When enabled, notification will be sent to the user that triggered the event in addition to any users in the group above. The event user will always be the first user, to send a notification only to the event user enabled 'Send once' in the notification transport.",
)}
>
</ak-switch-input>

View File

@@ -80,7 +80,7 @@ export class RuleListPage extends TablePage<NotificationRule> {
protected override row(item: NotificationRule): SlottedTemplateResult[] {
const enabled = !!item.destinationGroupObj || item.destinationEventUser;
return [
html`<ak-status-label type="warning" ?good=${enabled}></ak-status-label>`,
html`<ak-status-label ?good=${enabled}></ak-status-label>`,
html`${item.name}`,
html`${severityToLabel(item.severity)}`,
html`${item.destinationGroupObj

View File

@@ -234,7 +234,7 @@ export class LifecycleRuleForm extends ModelForm<LifecycleRule, string, Lifecycl
${this.renderReviewerGroupsSelection()}
</ak-form-element-horizontal>
<ak-number-input
label=${msg("Min reviewers")}
label=${msg("Minimum reviewers")}
min=${1}
name="minReviewers"
value="${this.instance?.minReviewers ?? 1}"
@@ -245,7 +245,7 @@ export class LifecycleRuleForm extends ModelForm<LifecycleRule, string, Lifecycl
<ak-switch-input
name="minReviewersIsPerGroup"
?checked=${this.instance?.minReviewersIsPerGroup ?? false}
label=${msg("Min reviewers is per-group")}
label=${msg("Minimum reviewers is per-group")}
.help=${msg(
html`If checked, approving a review will require at least that many users from
<em>each</em> of the selected groups. When disabled, the value is a total

View File

@@ -26,7 +26,6 @@ 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 override searchPlaceholder = msg("Search for a lifecycle rule by name or target...");
@@ -95,26 +94,6 @@ export class LifecycleRuleListPage extends TablePage<LifecycleRule> {
];
}
protected override renderExpanded(item: LifecycleRule): SlottedTemplateResult {
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
search-placeholder=${msg("Search tasks...")}
.relObjAppLabel=${appLabel}
.relObjModel=${modelName}
.relObjId="${item.id}"
></ak-task-list>
</div>
</dd>
</div>
</dl>`;
}
protected override renderObjectCreate(): SlottedTemplateResult {
return ModalInvokerButton(LifecycleRuleForm);
}

View File

@@ -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<Review> {
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<Review> {
@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<PaginatedResponse<Review>> {
#refreshListener = () => {
return this.fetch();
};
public override connectedCallback(): void {
super.connectedCallback();
this.addEventListener(EVENT_REFRESH, this.#refreshListener);
}
public async fetch(): Promise<void> {
if (!this.model || !this.objectPk) {
return Promise.resolve(createPaginatedResponse<Review>());
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<Review>();
this.iterations = null;
}
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(): SlottedTemplateResult {
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 null;
}
return ModalInvokerButton(ObjectReviewForm, {
iteration: this.iteration,
});
}
protected override render(): SlottedTemplateResult {
return html`<ak-lifecycle-preview-banner></ak-lifecycle-preview-banner>
<div class="pf-c-page__main-section pf-m-no-padding-mobile">
<div class="pf-l-grid pf-m-gutter">
${this.renderReviewSummary()}
<div class="pf-c-card pf-l-grid__item pf-m-9-col">
<div class="pf-c-card__title">${msg("Reviews")}</div>
${super.render()}
</div>
</div>
return html`
<ak-lifecycle-preview-banner></ak-lifecycle-preview-banner>
<div class="pf-l-grid pf-m-gutter pf-c-page__main-section pf-m-no-padding-mobile">
<h2 class="pf-c-title pf-m-xl">
${this.iterations?.length
? msg("The following reviews apply to this object:")
: msg("This object has no reviews yet.")}
</h2>
${this.iterations?.map(
(i) =>
html` <h3 class="pf-c-title pf-m-lg">${i.rule.name}</h3>
<ak-object-review-iteration
.iteration=${i}
class="pf-u-pl-lg-on-lg"
></ak-object-review-iteration>`,
)}
</div>
</div>`;
`;
}
//#endregion

View File

@@ -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<Review> {
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<this>): void {
super.updated(changedProperties);
if (changedProperties.has("iteration")) {
this.fetch();
}
}
protected apiEndpoint(): Promise<PaginatedResponse<Review>> {
if (!this.iteration) {
return Promise.resolve(createPaginatedResponse<Review>());
}
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`<span>${msg("No review iteration found for this object.")}</span>`;
}
const { reviewers, reviewerGroups, minReviewers } = this.iteration.rule;
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 renderToolbar(): SlottedTemplateResult {
return html`${this.renderObjectCreate()}
<ak-spinner-button .callAction=${this.#triggerRefresh} class="pf-m-secondary">
${msg("Refresh")}
</ak-spinner-button>`;
}
protected row(item: Review): SlottedTemplateResult[] {
return [
Timestamp(item.timestamp),
html`<span>${item.reviewer.name}</span>`,
html`<span>${item.note}</span>`,
];
}
protected override renderEmpty(): SlottedTemplateResult {
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 null;
}
return ModalInvokerButton(ObjectReviewForm, {
iteration: this.iteration,
});
}
protected override render(): SlottedTemplateResult {
return html` <div class="pf-l-grid pf-m-gutter">
${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>`;
}
//#endregion
//#endregion
}
declare global {
interface HTMLElementTagNameMap {
"ak-object-review-iteration": ObjectReviewIteration;
}
}

View File

@@ -70,6 +70,7 @@ export class ReviewListPage extends TablePage<LifecycleIteration> {
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<LifecycleIteration> {
return [
LifecycleIterationStatus({ status: item.state }),
html`<a href="#${item.objectAdminUrl}">${item.objectVerbose}</a>`,
html`${item.rule.name}`,
html`<ak-timestamp .timestamp=${item.openedOn} datetime dateonly></ak-timestamp>`,
html`<ak-timestamp .timestamp=${item.gracePeriodEnd} datetime dateonly></ak-timestamp>`,
];

View File

@@ -8,7 +8,7 @@ export abstract class BaseStageForm<T extends Stage> extends ModelForm<T, string
public static override verboseName = msg("Stage");
public static override verboseNamePlural = msg("Stages");
getSuccessMessage(): string {
public override getSuccessMessage(): string {
return this.instance
? msg("Successfully updated stage.")
: msg("Successfully created stage.");

View File

@@ -0,0 +1,119 @@
import "#elements/forms/HorizontalFormElement";
import "#admin/common/ak-flow-search/ak-flow-search";
import "#components/ak-switch-input";
import "#components/ak-text-input";
import { DEFAULT_CONFIG } from "#common/api/config";
import { SlottedTemplateResult } from "#elements/types";
import { ifPresent } from "#elements/utils/attributes";
import { BaseStageForm } from "#admin/stages/BaseStageForm";
import { AccountLockdownStage, StagesApi } from "@goauthentik/api";
import { msg } from "@lit/localize";
import { html } from "lit";
import { customElement } from "lit/decorators.js";
@customElement("ak-stage-account-lockdown-form")
export class AccountLockdownStageForm extends BaseStageForm<AccountLockdownStage> {
#api = new StagesApi(DEFAULT_CONFIG);
protected override loadInstance(pk: string): Promise<AccountLockdownStage> {
return this.#api.stagesAccountLockdownRetrieve({ stageUuid: pk });
}
protected override async send(data: AccountLockdownStage): Promise<AccountLockdownStage> {
if (this.instance) {
return this.#api.stagesAccountLockdownUpdate({
stageUuid: this.instance.pk || "",
accountLockdownStageRequest: data,
});
}
return this.#api.stagesAccountLockdownCreate({
accountLockdownStageRequest: data,
});
}
protected override renderForm(): SlottedTemplateResult {
return html`<span>
${msg(
"This stage executes account lockdown actions on a target user. Configure which actions to perform when this stage runs.",
)}
</span>
<ak-text-input
label=${msg("Stage Name")}
placeholder=${msg("Type a name for this stage...")}
required
name="name"
value=${ifPresent(this.instance?.name || "")}
?autofocus=${!this.instance}
></ak-text-input>
<ak-form-group open label=${msg("Stage-specific settings")}>
<div class="pf-c-form">
<ak-switch-input
name="deactivateUser"
label=${msg("Deactivate user")}
?checked=${this.instance?.deactivateUser ?? true}
help=${msg("Deactivate the user account (set is_active to False).")}
>
</ak-switch-input>
<ak-switch-input
name="setUnusablePassword"
label=${msg("Set unusable password")}
?checked=${this.instance?.setUnusablePassword ?? true}
help=${msg("Set an unusable password for the user.")}
>
</ak-switch-input>
<ak-switch-input
name="deleteSessions"
label=${msg("Delete sessions")}
?checked=${this.instance?.deleteSessions ?? true}
help=${msg("Delete all active sessions for the user.")}
>
</ak-switch-input>
<ak-switch-input
name="revokeTokens"
label=${msg("Revoke tokens")}
?checked=${this.instance?.revokeTokens ?? true}
help=${msg(
"Revoke all tokens for the user (API, app password, recovery, verification).",
)}
>
</ak-switch-input>
</div>
</ak-form-group>
<ak-form-group
label=${msg("Self-service completion")}
open
description=${msg(
"Configure what happens after a user locks their own account. Since all sessions are deleted, the user cannot continue in the current flow and will be redirected to a separate completion flow.",
)}
>
<div class="pf-c-form">
<ak-form-element-horizontal
label=${msg("Completion flow")}
name="selfServiceCompletionFlow"
>
<ak-flow-search
placeholder=${msg("Select a completion flow...")}
.currentFlow=${this.instance?.selfServiceCompletionFlow}
></ak-flow-search>
<p class="pf-c-form__helper-text">
${msg(
"Flow to redirect users to after self-service lockdown. This flow must not require authentication since the user's session is deleted.",
)}
</p>
</ak-form-element-horizontal>
</div>
</ak-form-group>`;
}
}
declare global {
interface HTMLElementTagNameMap {
"ak-stage-account-lockdown-form": AccountLockdownStageForm;
}
}

View File

@@ -148,6 +148,9 @@ export class PromptForm extends ModelForm<Prompt, string> {
[PromptTypeEnum.Separator, msg("Separator: Static Separator Line")],
[PromptTypeEnum.Hidden, msg("Hidden: Hidden field, can be used to insert data into form.")],
[PromptTypeEnum.Static, msg("Static: Static value, displayed as-is.")],
[PromptTypeEnum.AlertInfo, msg("Alert (Info): Static alert box with info styling")],
[PromptTypeEnum.AlertWarning, msg("Alert (Warning): Static alert box with warning styling")],
[PromptTypeEnum.AlertDanger, msg("Alert (Danger): Static alert box with danger styling")],
[PromptTypeEnum.AkLocale, msg("authentik: Locale: Displays a list of locales authentik supports.")],
];
const currentType = this.instance?.type;

View File

@@ -22,6 +22,7 @@ import "#admin/stages/password/PasswordStageForm";
import "#admin/stages/prompt/PromptStageForm";
import "#admin/stages/redirect/RedirectStageForm";
import "#admin/stages/source/SourceStageForm";
import "#admin/stages/account_lockdown/AccountLockdownStageForm";
import "#admin/stages/user_delete/UserDeleteStageForm";
import "#admin/stages/user_login/UserLoginStageForm";
import "#admin/stages/user_logout/UserLogoutStageForm";

View File

@@ -4,9 +4,7 @@ import "#elements/forms/FormGroup";
import { DEFAULT_CONFIG } from "#common/api/config";
import { formatDisambiguatedUserDisplayName } from "#common/users";
import { RawContent } from "#elements/ak-table/ak-simple-table";
import { modalInvoker } from "#elements/dialogs";
import { pluckEntityName } from "#elements/entities/names";
import { DestructiveModelForm } from "#elements/forms/DestructiveModelForm";
import { WithLocale } from "#elements/mixins/locale";
import { SlottedTemplateResult } from "#elements/types";
@@ -77,41 +75,6 @@ export class UserActivationToggleForm extends WithLocale(DestructiveModelForm<Us
return this.coreAPI.coreUsersUsedByList({ id: this.instance.pk });
};
protected override renderUsedBySection(): SlottedTemplateResult {
if (this.instance?.isActive) {
return super.renderUsedBySection();
}
const displayName = this.formatDisplayName();
const { usedByList, verboseName } = this;
return html`<ak-form-group
open
label=${msg("Objects associated with this user", {
id: "usedBy.associated-objects.label",
})}
>
<div
class="pf-m-monospace"
aria-description=${msg(
str`List of objects that are associated with this ${verboseName}.`,
{
id: "usedBy.description",
},
)}
slot="description"
>
${displayName}
</div>
<ak-simple-table
.columns=${[msg("Object Name"), msg("ID")]}
.content=${usedByList.map((ub): RawContent[] => {
return [pluckEntityName(ub) || msg("Unnamed"), html`<code>${ub.pk}</code>`];
})}
></ak-simple-table>
</ak-form-group>`;
}
}
declare global {

View File

@@ -22,6 +22,7 @@ import { formatUserDisplayName } from "#common/users";
import { IconEditButton, modalInvoker } from "#elements/dialogs";
import { WithBrandConfig } from "#elements/mixins/branding";
import { CapabilitiesEnum, WithCapabilitiesConfig } from "#elements/mixins/capabilities";
import { WithLicenseSummary } from "#elements/mixins/license";
import { WithSession } from "#elements/mixins/session";
import { getURLParam, updateURLParams } from "#elements/router/RouteMatch";
import { PaginatedResponse, TableColumn, Timestamp } from "#elements/table/Table";
@@ -56,8 +57,8 @@ const recoveryButtonStyles = css`
`;
@customElement("ak-user-list")
export class UserListPage extends WithBrandConfig(
WithCapabilitiesConfig(WithSession(TablePage<User>)),
export class UserListPage extends WithLicenseSummary(
WithBrandConfig(WithCapabilitiesConfig(WithSession(TablePage<User>))),
) {
static styles: CSSResult[] = [
...TablePage.styles,
@@ -195,7 +196,7 @@ export class UserListPage extends WithBrandConfig(
</div>
<h4 class="pf-c-alert__title">
${msg(
str`Warning: You're about to delete the user you're logged in as (${shouldShowWarning.username}). Proceed at your own risk.`,
str`Warning: You are about to delete user ${shouldShowWarning.username}, but you are currently logged in as this user. Proceed at your own risk.`,
)}
</h4>
</div>

View File

@@ -30,7 +30,11 @@ import "#elements/ak-mdx/ak-mdx";
import { DEFAULT_CONFIG } from "#common/api/config";
import { AKRefreshEvent } from "#common/events";
import { userTypeToLabel } from "#common/labels";
import { formatDisambiguatedUserDisplayName, formatUserDisplayName } from "#common/users";
import {
formatDisambiguatedUserDisplayName,
formatUserDisplayName,
startAccountLockdown,
} from "#common/users";
import { AKElement } from "#elements/Base";
import { listen } from "#elements/decorators/listen";
@@ -41,7 +45,6 @@ import { WithLicenseSummary } from "#elements/mixins/license";
import { WithLocale } from "#elements/mixins/locale";
import { WithSession } from "#elements/mixins/session";
import { Timestamp } from "#elements/table/shared";
import { SlottedTemplateResult } from "#elements/types";
import { setPageDetails } from "#components/ak-page-navbar";
import { type DescriptionPair, renderDescriptionList } from "#components/DescriptionList";
@@ -151,19 +154,21 @@ export class UserViewPage extends WithLicenseSummary(
const user = this.user;
// prettier-ignore
const userInfo: DescriptionPair[] = [
[ msg("Username"), user.username ],
[ msg("Name"), user.name ],
[ msg("Email"), user.email || "-" ],
[ msg("Last login"), Timestamp(user.lastLogin) ],
[ msg("Last password change"), Timestamp(user.passwordChangeDate) ],
[ msg("Active"), html`<ak-status-label ?good=${user.isActive}></ak-status-label>` ],
[ msg("Type"), userTypeToLabel(user.type) ],
[ msg("Superuser"), html`<ak-status-label type="warning" ?good=${user.isSuperuser}></ak-status-label>` ],
[ msg("Actions"), this.renderActionButtons(user) ],
[ msg("Recovery"), this.renderRecoveryButtons(user) ],
]
[msg("Username"), user.username],
[msg("Name"), user.name],
[msg("Email"), user.email || "-"],
[msg("Last login"), Timestamp(user.lastLogin)],
[msg("Last password change"), Timestamp(user.passwordChangeDate)],
[msg("Active"), html`<ak-status-label ?good=${user.isActive}></ak-status-label>`],
[msg("Type"), userTypeToLabel(user.type)],
[
msg("Superuser"),
html`<ak-status-label type="warning" ?good=${user.isSuperuser}></ak-status-label>`,
],
[msg("Actions"), this.renderActionButtons(user)],
[msg("Recovery"), this.renderRecoveryButtons(user)],
];
return html`
<div class="pf-c-card__title">${msg("User Info")}</div>
@@ -173,9 +178,21 @@ export class UserViewPage extends WithLicenseSummary(
`;
}
protected renderActionButtons(user: User): SlottedTemplateResult {
/**
* Initiates the account lockdown flow for this user, if any.
*/
protected lockdownUser = async () => {
if (!this.user) {
return;
}
return startAccountLockdown(this.user.pk).catch(showAPIErrorMessage);
};
protected renderActionButtons(user: User) {
const showImpersonate =
this.can(CapabilitiesEnum.CanImpersonate) && user.pk !== this.currentUser?.pk;
const showLockdown = this.hasEnterpriseLicense && user.pk !== this.currentUser?.pk;
const displayName = formatUserDisplayName(user);
@@ -188,6 +205,15 @@ export class UserViewPage extends WithLicenseSummary(
</button>
${ToggleUserActivationButton(user, { className: "pf-m-block" })}
${showLockdown
? html`<button
class="pf-c-button pf-m-danger pf-m-block"
@click=${this.lockdownUser}
type="button"
>
${msg("Account Lockdown")}
</button>`
: null}
${showImpersonate
? html`<button
class="pf-c-button pf-m-tertiary pf-m-block"

View File

@@ -157,6 +157,18 @@ export function redirectToAuthFlow(nextPathname = "/flows/-/default/authenticati
window.location.assign(authFlowRedirectURL);
}
/**
* Start account lockdown and follow the returned flow link.
*/
export async function startAccountLockdown(user?: number): Promise<void> {
const response = await new CoreApi(DEFAULT_CONFIG).coreUsersAccountLockdownCreate({
userAccountLockdownRequest: user !== undefined ? { user } : {},
});
if (response.link) {
window.location.assign(response.link);
}
}
/**
* Retrieve the current user session.
*

View File

@@ -19,13 +19,14 @@ import {
} from "@goauthentik/api";
import { msg } from "@lit/localize";
import { css, CSSResult, html, nothing } from "lit";
import { css, CSSResult, html } from "lit";
import { customElement } from "lit/decorators.js";
import { unsafeHTML } from "lit/directives/unsafe-html.js";
import PFAlert from "@patternfly/patternfly/components/Alert/alert.css";
import PFButton from "@patternfly/patternfly/components/Button/button.css";
import PFCheck from "@patternfly/patternfly/components/Check/check.css";
import PFContent from "@patternfly/patternfly/components/Content/content.css";
import PFForm from "@patternfly/patternfly/components/Form/form.css";
import PFFormControl from "@patternfly/patternfly/components/FormControl/form-control.css";
import PFInputGroup from "@patternfly/patternfly/components/InputGroup/input-group.css";
@@ -47,6 +48,7 @@ export class PromptStage extends WithCapabilitiesConfig(
static styles: CSSResult[] = [
PFLogin,
PFAlert,
PFContent,
PFForm,
PFFormControl,
PFInputGroup,
@@ -62,6 +64,30 @@ export class PromptStage extends WithCapabilitiesConfig(
`,
];
protected isAlertPromptType(prompt: StagePrompt): boolean {
return (
prompt.type === PromptTypeEnum.AlertInfo ||
prompt.type === PromptTypeEnum.AlertWarning ||
prompt.type === PromptTypeEnum.AlertDanger
);
}
protected renderPromptAlert(
prompt: StagePrompt,
level: "info" | "warning" | "danger",
icon: string,
): SlottedTemplateResult {
return html`<div class="pf-c-alert pf-m-${level} pf-m-inline">
<div class="pf-c-alert__icon">
<i class="fas fa-fw ${icon}" aria-hidden="true"></i>
</div>
${prompt.label ? html`<h4 class="pf-c-alert__title">${prompt.label}</h4>` : null}
<div class="pf-c-alert__description pf-c-content">
${unsafeHTML(prompt.initialValue)}
</div>
</div>`;
}
protected renderPromptInner(prompt: StagePrompt): SlottedTemplateResult {
const fieldId = `field-${prompt.fieldKey}`;
@@ -194,6 +220,12 @@ ${prompt.initialValue}</textarea
/>`;
case PromptTypeEnum.Static:
return html`<p>${unsafeHTML(prompt.initialValue)}</p>`;
case PromptTypeEnum.AlertInfo:
return this.renderPromptAlert(prompt, "info", "fa-info-circle");
case PromptTypeEnum.AlertWarning:
return this.renderPromptAlert(prompt, "warning", "fa-exclamation-triangle");
case PromptTypeEnum.AlertDanger:
return this.renderPromptAlert(prompt, "danger", "fa-exclamation-circle");
case PromptTypeEnum.Dropdown:
return html`<select class="pf-c-form-control" name="${prompt.fieldKey}">
${prompt.choices?.map((choice) => {
@@ -234,9 +266,9 @@ ${prompt.initialValue}</textarea
}
}
protected renderPromptHelpText(prompt: StagePrompt) {
protected renderPromptHelpText(prompt: StagePrompt): SlottedTemplateResult {
if (!prompt.subText) {
return nothing;
return null;
}
return html`<p class="pf-c-form__helper-text">${unsafeHTML(prompt.subText)}</p>`;
@@ -247,7 +279,8 @@ ${prompt.initialValue}</textarea
return !(
prompt.type === PromptTypeEnum.Static ||
prompt.type === PromptTypeEnum.Hidden ||
prompt.type === PromptTypeEnum.Separator
prompt.type === PromptTypeEnum.Separator ||
this.isAlertPromptType(prompt)
);
}
@@ -266,7 +299,7 @@ ${prompt.initialValue}</textarea
<label class="pf-c-check__label" for="${prompt.fieldKey}">${prompt.label}</label>
${prompt.required
? html`<p class="pf-c-form__helper-text">${msg("Required.")}</p>`
: nothing}
: null}
<p class="pf-c-form__helper-text">${unsafeHTML(prompt.subText)}</p>
</div>`;
}

View File

@@ -9,10 +9,14 @@ import "#user/user-settings/tokens/UserTokenList";
import { DEFAULT_CONFIG } from "#common/api/config";
import { EVENT_REFRESH } from "#common/constants";
import { startAccountLockdown } from "#common/users";
import { AKSkipToContent } from "#elements/a11y/ak-skip-to-content";
import { AKElement } from "#elements/Base";
import { showAPIErrorMessage } from "#elements/messages/MessageContainer";
import { WithLicenseSummary } from "#elements/mixins/license";
import { WithSession } from "#elements/mixins/session";
import { SlottedTemplateResult } from "#elements/types";
import { ifPresent } from "#elements/utils/attributes";
import Styles from "#user/user-settings/styles.css";
@@ -20,10 +24,11 @@ import Styles from "#user/user-settings/styles.css";
import { StagesApi, UserSetting } from "@goauthentik/api";
import { msg } from "@lit/localize";
import { CSSResult, html, nothing, TemplateResult } from "lit";
import { CSSResult, html, nothing } from "lit";
import { customElement, state } from "lit/decorators.js";
import { ifDefined } from "lit/directives/if-defined.js";
import PFButton from "@patternfly/patternfly/components/Button/button.css";
import PFCard from "@patternfly/patternfly/components/Card/card.css";
import PFContent from "@patternfly/patternfly/components/Content/content.css";
import PFDescriptionList from "@patternfly/patternfly/components/DescriptionList/description-list.css";
@@ -36,9 +41,10 @@ import PFDisplay from "@patternfly/patternfly/utilities/Display/display.css";
import PFSizing from "@patternfly/patternfly/utilities/Sizing/sizing.css";
@customElement("ak-user-settings")
export class UserSettingsPage extends WithSession(AKElement) {
export class UserSettingsPage extends WithLicenseSummary(WithSession(AKElement)) {
static styles: CSSResult[] = [
PFPage,
PFButton,
PFDisplay,
PFGallery,
PFContent,
@@ -51,21 +57,69 @@ export class UserSettingsPage extends WithSession(AKElement) {
Styles,
];
protected stagesAPI = new StagesApi(DEFAULT_CONFIG);
@state()
userSettings?: UserSetting[];
protected userSettings: UserSetting[] | null = null;
protected refresh = () => {
return this.stagesAPI
.stagesAllUserSettingsList()
.then((nextUserSettings) => {
this.userSettings = nextUserSettings;
})
.catch(showAPIErrorMessage);
};
constructor() {
super();
this.addEventListener(EVENT_REFRESH, () => {
this.firstUpdated();
});
this.addEventListener(EVENT_REFRESH, this.refresh);
}
async firstUpdated(): Promise<void> {
this.userSettings = await new StagesApi(DEFAULT_CONFIG).stagesAllUserSettingsList();
public async firstUpdated(): Promise<void> {
this.refresh();
}
render(): TemplateResult {
protected lockAccount = () => {
return startAccountLockdown().catch(showAPIErrorMessage);
};
protected renderSecuritySettings(): SlottedTemplateResult {
if (!this.hasEnterpriseLicense) {
return null;
}
return html`<div
id="page-security"
role="tabpanel"
tabindex="0"
slot="page-security"
aria-label=${msg("Security")}
class="pf-c-page__main-section pf-m-no-padding-mobile"
>
<div class="pf-l-stack pf-m-gutter">
<div class="pf-l-stack__item">
<div class="pf-c-card">
<div class="pf-c-card__title">${msg("Account Lockdown")}</div>
<div class="pf-c-card__body">
<p>
${msg(
"If you suspect your account has been compromised, you can immediately lock it to prevent unauthorized access.",
)}
</p>
</div>
<div class="pf-c-card__footer">
<button class="pf-c-button pf-m-danger" @click=${this.lockAccount}>
${msg("Lock my account")}
</button>
</div>
</div>
</div>
</div>
</div>`;
}
protected override render(): SlottedTemplateResult {
const pwStage =
this.userSettings?.filter((stage) => stage.component === "ak-user-settings-password") ||
[];
@@ -176,6 +230,7 @@ export class UserSettingsPage extends WithSession(AKElement) {
></ak-user-settings-source>
</div>
</div>
${this.renderSecuritySettings()}
</ak-tabs>
</div>
</div>`;

View File

@@ -0,0 +1,117 @@
---
title: Account Lockdown stage
authentik_version: "2025.5.0"
authentik_enterprise: true
---
:::danger
This stage performs destructive actions on a user account. Ensure the flow includes appropriate warnings and confirmation steps before this stage executes.
:::
The Account Lockdown stage executes security lockdown actions on a target user account. For the feature overview and usage instructions, see [Account Lockdown](../../../../security/account-lockdown.md).
## Stage behavior
1. **Resolves the target account** from the flow query parameters (see [Target user resolution](#target-user-resolution))
2. **Applies the configured actions** to that account
3. **Creates an event** for the locked account
4. **For self-service**: if sessions are deleted, redirects to the completion flow
## Stage settings
| Setting | Description | Default |
| ------------------------- | ----------------------------------------------------------------------------------- | ------- |
| **Deactivate user** | Set `is_active` to False | Enabled |
| **Set unusable password** | Invalidate the local authentik password. External source passwords are not changed. | Enabled |
| **Delete sessions** | Terminate all active sessions | Enabled |
| **Revoke tokens** | Delete all tokens and grants (API, app password, recovery, verification, OAuth) | Enabled |
| **Completion flow** | Flow for self-service completion (must not require auth) | None |
:::warning
Disabling **Delete sessions** is not recommended as it would allow an attacker with an active session to continue using the account.
:::
## Target user resolution
The account lockdown API pre-plans the flow with the selected user as the flow's `pending_user`. The stage uses that `pending_user` as the account to lock.
The resolved target must be:
- a real user account
- not the anonymous user
- not an internal service account
If no valid target user can be resolved, the stage returns an invalid response.
## Reason input
The stage reads the lockdown reason from:
1. `prompt_data.lockdown_reason`
2. `lockdown_reason` in the flow plan context
3. an empty string if neither is set
## Self-service behavior
When the resolved target user is the same user who is currently authenticated and **Delete sessions** is enabled, the user's session is deleted during lockdown. The stage cannot continue to the next stage, so it redirects to the **Completion flow**.
If **Delete sessions** is disabled, the flow continues normally and can show its own completion stages.
The completion flow must have **Authentication** set to **No authentication required**.
## Events
Creates a **User Write** event with an account-lockdown action ID. Use [Notification Rules](../../../../sys-mgmt/events/index.md) to send alerts. To match account-lockdown events, use action `user_write` and query `context.action_id = "account_lockdown"`.
```json
{
"action": "user_write",
"context": {
"action_id": "account_lockdown",
"reason": "User-provided reason",
"affected_user": "username"
}
}
```
## Usage examples
### Policy to show a completion stage only for administrator-triggered lockdowns
```python
target_user = request.context.get("pending_user")
current_user = request.http_request.user
return bool(target_user and current_user and target_user.pk != current_user.pk)
```
### Dynamic warning message
Prompt field with **Initial value expression** enabled:
```python
target = user
current_user = http_request.user
is_self_service = bool(target and current_user and target.pk == current_user.pk)
from django.utils.html import escape
if is_self_service:
return """<p><strong>This will immediately:</strong></p>
<ul>
<li>Invalidate your local authentik password</li>
<li>Deactivate your account</li>
<li>Terminate all sessions</li>
<li>Revoke all tokens</li>
</ul>"""
else:
if target:
return f"<p><strong>Locking down:</strong></p><p><code>{escape(target.username)}</code></p>"
return "<p><strong>Locking down the selected account.</strong></p>"
```
## Error handling
| Error | Cause |
| -------------------------- | --------------------------------------- |
| "No target user specified" | No valid pending user found in the flow |
| Failure | The stage returns an invalid response |

View File

@@ -0,0 +1,128 @@
---
title: Account Lockdown
authentik_version: "2025.5.0"
authentik_enterprise: true
---
Account Lockdown is a security feature that allows administrators to quickly secure a user account during emergencies, such as suspected compromise or unauthorized access. Users can also lock down their own account if they believe it has been compromised.
## What Account Lockdown does
When triggered, Account Lockdown performs the following actions (all configurable):
- **Deactivates the user account**: The user can no longer log in
- **Sets an unusable password**: Invalidates the user's password
- **Terminates all active sessions**: Immediately logs the user out of all devices and applications
- **Revokes all tokens**: Invalidates API, app password, recovery, verification, and OAuth2 tokens and grants
- **Creates an audit event**: Records the lockdown with the provided reason (can trigger [notifications](#configure-notifications))
:::note Protected accounts
Account Lockdown cannot be triggered on the anonymous user or internal service accounts.
:::
## Prerequisites
1. A **Lockdown Flow** must be configured on your Brand (**System** > **Brands**)
2. The flow must contain an [Account Lockdown Stage](../add-secure-apps/flows-stages/stages/account_lockdown/index.md) (Enterprise)
3. For self-service lockdown, configure a **Completion Flow** on the stage
## Use the packaged lockdown blueprint
authentik includes a packaged lockdown blueprint that creates a default lockdown flow (`default-account-lockdown`) and a self-service completion flow (`default-account-lockdown-complete`).
The blueprint creates:
| Order | Stage | Purpose |
| ----- | ------------------------- | -------------------------------- |
| 0 | Prompt Stage | Warning message and reason input |
| 10 | Account Lockdown Stage | Executes lockdown actions |
| 20 | Prompt Stage (admin only) | Shows a confirmation message |
A separate completion flow (`default-account-lockdown-complete`) displays a message after self-service lockdowns.
### Step 1. Download the blueprint
Download the lockdown blueprint by running:
```shell
wget https://goauthentik.io/blueprints/example/flow-default-account-lockdown.yaml
```
Alternatively, use this [link](/blueprints/example/flow-default-account-lockdown.yaml) to view and save the file.
### Step 2. Import the blueprint file
1. Log in to authentik as an administrator and open the authentik Admin interface.
2. Navigate to **Flows and Stages** > **Flows** and click **Import**.
3. Click **Choose file**, select `flow-default-account-lockdown.yaml`, and then click **Import**.
### Step 3. Set the lockdown flow on your brand
1. Navigate to **System** > **Brands**.
2. Edit your brand and set **Lockdown flow** to `default-account-lockdown`.
## Create a custom flow
1. Navigate to **Flows and Stages** > **Flows** and create a flow with:
- **Designation**: Stage Configuration
- **Authentication**: Require authenticated user
2. Add a Prompt Stage for warnings and reason collection
3. Add an Account Lockdown Stage
4. Optionally add an administrator-only completion Prompt Stage
5. Set this flow as **Lockdown flow** on your Brand
For stage configuration details, see the [Account Lockdown Stage documentation](../add-secure-apps/flows-stages/stages/account_lockdown/index.md).
## Trigger an Account Lockdown
### From a User's detail page
1. Navigate to **Directory** > **Users** and click on a user.
2. Click **Account Lockdown**.
3. Review the warning, enter a reason (recorded in the audit log), and click **Continue**.
4. If your flow includes an administrator-only completion stage, it is shown after the lockdown completes.
## Self-service Account Lockdown
Users can lock their own account from the User interface:
1. Navigate to **Settings**.
2. In the **Account Lockdown** section, click **Lock my account**.
3. Enter a reason and click **Continue**.
After lockdown, the user is redirected to the configured completion page. They cannot log back in until an administrator restores access.
### Configure the completion message
Since the user's session is deleted, the stage redirects to a separate unauthenticated flow:
1. Create a flow with **Authentication** set to **No authentication required**
2. Add a Prompt Stage with an alert field containing your message
3. On your Account Lockdown Stage, set **Completion flow** to this flow
## Configure notifications
Use Notification Rules to alert when lockdowns occur:
1. Navigate to **Customization** > **Policies** and create an **Event Matcher Policy**
2. Set **Action** to **User Write**
3. Set **Query** to `action = "user_write" and context.action_id = "account_lockdown"`
4. Navigate to **Events** > **Notification Rules** and create a rule
5. Select a notification transport, such as `default-email-transport`
6. Select a destination group, or enable **Send notification to event user** to notify the locked user
7. Bind the Event Matcher Policy to the rule
## Restore access after lockdown
1. Navigate to **Directory** > **Users** and find the locked user (shown as inactive).
2. Click **Activate** to re-enable the account.
3. Use **Set password** or **Create Recovery Link** to set a new password.
4. Advise the user to re-enroll MFA devices.
## Troubleshooting
| Issue | Solution |
| ----------------------------- | -------------------------------------------------------------------------------- |
| "No lockdown flow configured" | Set a lockdown flow on your Brand (**System** > **Brands**) |
| Self-service shows login page | Configure a **Completion flow** on the stage with **No authentication required** |
| Warning message not showing | Ensure **Initial value expression** is enabled and field type is an alert type |

View File

@@ -321,6 +321,7 @@ const items = [
id: "add-secure-apps/flows-stages/stages/index",
},
items: [
"add-secure-apps/flows-stages/stages/account_lockdown/index",
"add-secure-apps/flows-stages/stages/authenticator_duo/index",
"add-secure-apps/flows-stages/stages/authenticator_email/index",
"add-secure-apps/flows-stages/stages/authenticator_endpoint_gdtc/index",
@@ -972,6 +973,7 @@ const items = [
items: [
"security/policy",
"security/security-hardening",
"security/account-lockdown",
{
//#endregion

View File

@@ -54,7 +54,7 @@ After you've created the policies to match the events you want, create a notific
3. Define the policy configurations, and then click **Create Notification Rule** or **Update** to save the settings.
- Note that policies are executed regardless of whether a group is selected. However, notifications are only triggered when a group is selected.
- Note that policies are executed regardless of whether a destination is selected. However, notifications are only created when a destination group is selected or **Send notification to event user** is enabled.
- You also have to select which [notification transport](./transports.md) should be used to send the notification. Two notification transports are created by default:
- `default-email-transport`: Delivers notifications via email using the [global email configuration](../../install-config/install/docker-compose.mdx#email-configuration-optional-but-recommended).
- `default-local-transport`: Delivers notifications within the authentik UI.

View File

@@ -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: