diff --git a/authentik/admin/api/settings.py b/authentik/admin/api/settings.py new file mode 100644 index 0000000000..05b97aa17a --- /dev/null +++ b/authentik/admin/api/settings.py @@ -0,0 +1,113 @@ +"""Serializer for settings""" + +from typing import get_args + +from django.utils.translation import gettext_lazy as _ +from drf_spectacular.extensions import OpenApiSerializerFieldExtension +from drf_spectacular.plumbing import build_basic_type, build_object_type +from rest_framework.exceptions import ValidationError +from rest_framework.fields import JSONField +from rest_framework.generics import RetrieveUpdateAPIView +from rest_framework.permissions import SAFE_METHODS + +from authentik.admin.models import SystemSettings +from authentik.core.api.utils import JSONDictField, ModelSerializer +from authentik.rbac.permissions import HasPermission +from authentik.tenants.flags import Flag + + +class FlagJSONField(JSONDictField): + def to_internal_value(self, data: str): + flags = super().to_internal_value(data) + for flag in Flag.available(visibility="system", exclude_system=False): + flags[flag().key] = flag.get() + return flags + + def to_representation(self, value: dict) -> dict: + new_value = value.copy() + for flag in Flag.available(exclude_system=False): + _flag = flag() + # Exclude any system flags that aren't modifiable + if _flag.visibility == "system": + new_value.pop(_flag.key, None) + # Explicitly present unset flags as if they were set to default + if _flag.key not in value: + value[_flag.key] = _flag.default + return super().to_representation(new_value) + + def run_validators(self, value: dict): + super().run_validators(value) + for flag in Flag.available(): + _flag = flag() + if _flag.key not in value: + continue + flag_value = value.get(_flag.key) + flag_type = get_args(_flag.__orig_bases__[0])[0] + if flag_value and not isinstance(flag_value, flag_type): + raise ValidationError( + _("Value for flag {flag_key} needs to be of type {type}.").format( + flag_key=_flag.key, type=flag_type.__name__ + ) + ) + + +class FlagsJSONExtension(OpenApiSerializerFieldExtension): + """Generate API Schema for JSON fields as""" + + target_class = "authentik.tenants.api.settings.FlagJSONField" + + def map_serializer_field(self, auto_schema, direction): + props = {} + for flag in Flag.available(): + _flag = flag() + props[_flag.key] = build_basic_type(get_args(_flag.__orig_bases__[0])[0]) + if _flag.description: + props[_flag.key]["description"] = _flag.description + if _flag.deprecated: + props[_flag.key]["deprecated"] = _flag.deprecated + return build_object_type(props, required=props.keys()) + + +class SettingsSerializer(ModelSerializer): + """Settings Serializer""" + + footer_links = JSONField(required=False) + flags = FlagJSONField() + + class Meta: + model = SystemSettings + fields = [ + "avatars", + "default_user_change_name", + "default_user_change_email", + "default_user_change_username", + "event_retention", + "reputation_lower_limit", + "reputation_upper_limit", + "footer_links", + "gdpr_compliance", + "impersonation", + "impersonation_require_reason", + "default_token_duration", + "default_token_length", + "pagination_default_page_size", + "pagination_max_page_size", + "flags", + ] + + +class SettingsView(RetrieveUpdateAPIView): + """Settings view""" + + queryset = SystemSettings.objects.filter(pk=True) + serializer_class = SettingsSerializer + filter_backends = [] + + def get_permissions(self): + return [ + HasPermission( + "authentik_rbac.view_system_settings" + if self.request.method in SAFE_METHODS + else "authentik_rbac.edit_system_settings" + )() + ] diff --git a/authentik/admin/migrations/0001_initial.py b/authentik/admin/migrations/0001_initial.py new file mode 100644 index 0000000000..3b666cee86 --- /dev/null +++ b/authentik/admin/migrations/0001_initial.py @@ -0,0 +1,152 @@ +# Generated by Django 5.2.15 on 2026-06-17 14:56 + +import authentik.lib.models +import authentik.lib.utils.time +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [] + + operations = [ + migrations.CreateModel( + name="VersionHistory", + fields=[ + ("id", models.BigAutoField(primary_key=True, serialize=False)), + ("timestamp", models.DateTimeField()), + ("version", models.TextField()), + ("build", models.TextField()), + ], + options={ + "verbose_name": "Version history", + "verbose_name_plural": "Version history", + "db_table": "authentik_version_history", + "ordering": ("-timestamp",), + "managed": False, + "default_permissions": [], + }, + ), + migrations.CreateModel( + name="SystemSettings", + fields=[ + ("id", models.BooleanField(default=True, primary_key=True, serialize=False)), + ( + "avatars", + models.TextField( + default="gravatar,initials", + help_text="Configure how authentik should show avatars for users.", + ), + ), + ( + "default_user_change_name", + models.BooleanField( + default=True, help_text="Enable the ability for users to change their name." + ), + ), + ( + "default_user_change_email", + models.BooleanField( + default=False, + help_text="Enable the ability for users to change their email address.", + ), + ), + ( + "default_user_change_username", + models.BooleanField( + default=False, + help_text="Enable the ability for users to change their username.", + ), + ), + ( + "event_retention", + models.TextField( + default="days=365", + help_text="Events will be deleted after this duration.(Format: weeks=3;days=2;hours=3,seconds=2).", + validators=[authentik.lib.utils.time.timedelta_string_validator], + ), + ), + ( + "reputation_lower_limit", + models.IntegerField( + default=-5, + help_text="Reputation cannot decrease lower than this value. Zero or negative.", + validators=[django.core.validators.MaxValueValidator(0)], + ), + ), + ( + "reputation_upper_limit", + models.IntegerField( + default=5, + help_text="Reputation cannot increase higher than this value. Zero or positive.", + validators=[django.core.validators.MinValueValidator(0)], + ), + ), + ( + "footer_links", + models.JSONField( + blank=True, + default=list, + help_text="The option configures the footer links on the flow executor pages.", + ), + ), + ( + "gdpr_compliance", + models.BooleanField( + default=True, + help_text="When enabled, all the events caused by a user will be deleted upon the user's deletion.", + ), + ), + ( + "impersonation", + models.BooleanField( + default=True, help_text="Globally enable/disable impersonation." + ), + ), + ( + "impersonation_require_reason", + models.BooleanField( + default=True, + help_text="Require administrators to provide a reason for impersonating a user.", + ), + ), + ( + "default_token_duration", + models.TextField( + default="days=1", + help_text="Default token duration", + validators=[authentik.lib.utils.time.timedelta_string_validator], + ), + ), + ( + "default_token_length", + models.PositiveIntegerField( + default=60, + help_text="Default token length", + validators=[django.core.validators.MinValueValidator(1)], + ), + ), + ( + "pagination_default_page_size", + models.PositiveIntegerField( + default=20, + help_text="Default page size for API responses, if no size was requested.", + ), + ), + ( + "pagination_max_page_size", + models.PositiveIntegerField(default=100, help_text="Maximum page size"), + ), + ("flags", models.JSONField(default=dict)), + ], + options={ + "verbose_name": "System settings", + "verbose_name_plural": "System settings", + "default_permissions": [], + }, + bases=(authentik.lib.models.InternallyManagedMixin, models.Model), + ), + ] diff --git a/authentik/admin/migrations/__init__.py b/authentik/admin/migrations/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/authentik/admin/models.py b/authentik/admin/models.py index ee4a2561b6..ecdc59282c 100644 --- a/authentik/admin/models.py +++ b/authentik/admin/models.py @@ -1,7 +1,111 @@ """authentik admin models""" -from django.db import models +from django.core.validators import MaxValueValidator, MinValueValidator +from django.db import IntegrityError, models from django.utils.translation import gettext_lazy as _ +from rest_framework.serializers import Serializer + +from authentik.lib.models import InternallyManagedMixin, SerializerModel +from authentik.lib.utils.time import timedelta_string_validator + +DEFAULT_TOKEN_DURATION = "days=1" # nosec +DEFAULT_TOKEN_LENGTH = 60 +DEFAULT_REPUTATION_LOWER_LIMIT = -5 +DEFAULT_REPUTATION_UPPER_LIMIT = 5 + + +class SystemSettings(InternallyManagedMixin, SerializerModel): + id = models.BooleanField(primary_key=True, default=True) + + avatars = models.TextField( + help_text=_("Configure how authentik should show avatars for users."), + default="gravatar,initials", + ) + default_user_change_name = models.BooleanField( + help_text=_("Enable the ability for users to change their name."), default=True + ) + default_user_change_email = models.BooleanField( + help_text=_("Enable the ability for users to change their email address."), default=False + ) + default_user_change_username = models.BooleanField( + help_text=_("Enable the ability for users to change their username."), default=False + ) + event_retention = models.TextField( + default="days=365", + validators=[timedelta_string_validator], + help_text=_( + "Events will be deleted after this duration.(Format: weeks=3;days=2;hours=3,seconds=2)." + ), + ) + reputation_lower_limit = models.IntegerField( + help_text=_("Reputation cannot decrease lower than this value. Zero or negative."), + default=DEFAULT_REPUTATION_LOWER_LIMIT, + validators=[MaxValueValidator(0)], + ) + reputation_upper_limit = models.IntegerField( + help_text=_("Reputation cannot increase higher than this value. Zero or positive."), + default=DEFAULT_REPUTATION_UPPER_LIMIT, + validators=[MinValueValidator(0)], + ) + footer_links = models.JSONField( + help_text=_("The option configures the footer links on the flow executor pages."), + default=list, + blank=True, + ) + gdpr_compliance = models.BooleanField( + help_text=_( + "When enabled, all the events caused by a user " + "will be deleted upon the user's deletion." + ), + default=True, + ) + impersonation = models.BooleanField( + help_text=_("Globally enable/disable impersonation."), default=True + ) + impersonation_require_reason = models.BooleanField( + help_text=_("Require administrators to provide a reason for impersonating a user."), + default=True, + ) + default_token_duration = models.TextField( + help_text=_("Default token duration"), + default=DEFAULT_TOKEN_DURATION, + validators=[timedelta_string_validator], + ) + default_token_length = models.PositiveIntegerField( + help_text=_("Default token length"), + default=DEFAULT_TOKEN_LENGTH, + validators=[MinValueValidator(1)], + ) + + pagination_default_page_size = models.PositiveIntegerField( + help_text=_("Default page size for API responses, if no size was requested."), + default=20, + ) + pagination_max_page_size = models.PositiveIntegerField( + help_text=_("Maximum page size"), + default=100, + ) + + flags = models.JSONField(default=dict) + + class Meta: + verbose_name = _("System settings") + verbose_name_plural = _("System settings") + default_permissions = [] + + def __str__(self): + return "System settings" + + def save(self, *args, **kwargs): + if not self.pk: + raise IntegrityError("Only one instance of system settings is allowed") + super().save(*args, **kwargs) + + @property + def serializer(self) -> Serializer: + from authentik.admin.api.settings import SettingsSerializer + + return SettingsSerializer class VersionHistory(models.Model):