diff --git a/authentik/blueprints/tests/fixtures/rbac_object.yaml b/authentik/blueprints/tests/fixtures/rbac_object.yaml index 75c10eb877..5b69869d7c 100644 --- a/authentik/blueprints/tests/fixtures/rbac_object.yaml +++ b/authentik/blueprints/tests/fixtures/rbac_object.yaml @@ -18,7 +18,7 @@ entries: name: foo title: foo permissions: - - permission: view_flow + - permission: authentik_flows.view_flow user: !KeyOf user - - permission: view_flow + - permission: authentik_flows.view_flow role: !KeyOf role diff --git a/authentik/blueprints/v1/importer.py b/authentik/blueprints/v1/importer.py index 86a54e4cbc..a585959af5 100644 --- a/authentik/blueprints/v1/importer.py +++ b/authentik/blueprints/v1/importer.py @@ -15,7 +15,7 @@ from django.db.models import Model from django.db.models.query_utils import Q from django.db.transaction import atomic from django.db.utils import IntegrityError -from guardian.models import RoleObjectPermission, UserObjectPermission +from guardian.models import RoleObjectPermission from rest_framework.exceptions import ValidationError from rest_framework.serializers import BaseSerializer, Serializer from structlog.stdlib import BoundLogger, get_logger @@ -71,7 +71,6 @@ def excluded_models() -> list[type[Model]]: ContentType, Permission, RoleObjectPermission, - UserObjectPermission, # Base classes Provider, Source, diff --git a/authentik/core/api/applications.py b/authentik/core/api/applications.py index 2021fa8fd4..ee2af1321f 100644 --- a/authentik/core/api/applications.py +++ b/authentik/core/api/applications.py @@ -66,7 +66,7 @@ class ApplicationSerializer(ModelSerializer): user = self.context["request"].user # Cache serialized user data to avoid N+1 when formatting launch URLs - # for multiple applications. UserSerializer accesses user.ak_groups which + # for multiple applications. UserSerializer accesses user.groups which # would otherwise trigger a query for each application. if user is not None: if "_cached_user_data" not in self.context: diff --git a/authentik/core/api/users.py b/authentik/core/api/users.py index e4868dee40..e87d740524 100644 --- a/authentik/core/api/users.py +++ b/authentik/core/api/users.py @@ -129,7 +129,6 @@ class UserSerializer(ModelSerializer): groups = PrimaryKeyRelatedField( allow_empty=True, many=True, - source="ak_groups", queryset=Group.objects.all().order_by("name"), default=list, ) @@ -165,7 +164,7 @@ class UserSerializer(ModelSerializer): def get_groups_obj(self, instance: User) -> list[PartialGroupSerializer] | None: if not self._should_include_groups: return None - return PartialGroupSerializer(instance.ak_groups, many=True).data + return PartialGroupSerializer(instance.groups, many=True).data @extend_schema_field(RoleSerializer(many=True)) def get_roles_obj(self, instance: User) -> list[RoleSerializer] | None: @@ -421,7 +420,7 @@ class UsersFilter(FilterSet): last_login__gt = IsoDateTimeFilter(field_name="last_login", lookup_expr="gt") last_login__isnull = BooleanFilter(field_name="last_login", lookup_expr="isnull") - is_superuser = BooleanFilter(field_name="ak_groups", method="filter_is_superuser") + is_superuser = BooleanFilter(field_name="groups", method="filter_is_superuser") uuid = UUIDFilter(field_name="uuid") path = CharFilter(field_name="path") @@ -430,12 +429,12 @@ class UsersFilter(FilterSet): type = MultipleChoiceFilter(choices=UserTypes.choices, field_name="type") groups_by_name = ModelMultipleChoiceFilter( - field_name="ak_groups__name", + field_name="groups__name", to_field_name="name", queryset=Group.objects.all().order_by("name"), ) groups_by_pk = ModelMultipleChoiceFilter( - field_name="ak_groups", + field_name="groups", queryset=Group.objects.all().order_by("name"), ) @@ -451,8 +450,8 @@ class UsersFilter(FilterSet): def filter_is_superuser(self, queryset, name, value): if value: - return queryset.filter(ak_groups__is_superuser=True).distinct() - return queryset.exclude(ak_groups__is_superuser=True).distinct() + return queryset.filter(groups__is_superuser=True).distinct() + return queryset.exclude(groups__is_superuser=True).distinct() def filter_attributes(self, queryset, name, value): """Filter attributes by query args""" @@ -530,7 +529,7 @@ class UserViewSet( def get_queryset(self): base_qs = User.objects.all().exclude_anonymous() if self.serializer_class(context={"request": self.request})._should_include_groups: - base_qs = base_qs.prefetch_related("ak_groups") + base_qs = base_qs.prefetch_related("groups") if self.serializer_class(context={"request": self.request})._should_include_roles: base_qs = base_qs.prefetch_related("roles") return base_qs diff --git a/authentik/core/migrations/0057_remove_user_groups_remove_user_user_permissions_and_more.py b/authentik/core/migrations/0057_remove_user_groups_remove_user_user_permissions_and_more.py new file mode 100644 index 0000000000..4009cfff9a --- /dev/null +++ b/authentik/core/migrations/0057_remove_user_groups_remove_user_user_permissions_and_more.py @@ -0,0 +1,47 @@ +# Generated by Django 5.2.10 on 2026-01-19 21:46 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_core", "0056_user_roles"), + ("authentik_rbac", "0010_remove_role_group_alter_role_name"), + ] + + operations = [ + migrations.RemoveField( + model_name="user", + name="user_permissions", + ), + migrations.AlterField( + model_name="group", + name="roles", + field=models.ManyToManyField( + blank=True, related_name="groups", to="authentik_rbac.role" + ), + ), + migrations.RemoveField( + model_name="user", + name="groups", + ), + migrations.RenameField( + model_name="user", + old_name="ak_groups", + new_name="groups", + ), + migrations.AlterModelOptions( + name="user", + options={ + "permissions": [ + ("reset_user_password", "Reset Password"), + ("impersonate", "Can impersonate other users"), + ("preview_user", "Can preview user data sent to providers"), + ("view_user_applications", "View applications the user has access to"), + ], + "verbose_name": "User", + "verbose_name_plural": "Users", + }, + ), + ] diff --git a/authentik/core/models.py b/authentik/core/models.py index eb77b4fffe..bcebbad970 100644 --- a/authentik/core/models.py +++ b/authentik/core/models.py @@ -1,6 +1,6 @@ """authentik core models""" -from datetime import datetime +from datetime import datetime, timedelta from enum import StrEnum from hashlib import sha256 from typing import Any, Self @@ -183,7 +183,7 @@ class Group(SerializerModel, AttributesMixin): default=False, help_text=_("Users added to this group will be superusers.") ) - roles = models.ManyToManyField("authentik_rbac.Role", related_name="ak_groups", blank=True) + roles = models.ManyToManyField("authentik_rbac.Role", related_name="groups", blank=True) parents = models.ManyToManyField( "Group", @@ -232,7 +232,7 @@ class Group(SerializerModel, AttributesMixin): def all_roles(self) -> QuerySet[Role]: """Get all roles of this group and all of its ancestors.""" return Role.objects.filter( - ak_groups__in=Group.objects.filter(pk=self.pk).with_ancestors() + groups__in=Group.objects.filter(pk=self.pk).with_ancestors() ).distinct() def get_managed_role(self, create=False): @@ -240,7 +240,7 @@ class Group(SerializerModel, AttributesMixin): name = managed_role_name(self) role, created = Role.objects.get_or_create(name=name, managed=name) if created: - role.ak_groups.add(self) + role.groups.add(self) return role else: return Role.objects.filter(name=managed_role_name(self)).first() @@ -355,13 +355,17 @@ class UserManager(DjangoUserManager): class User(SerializerModel, AttributesMixin, AbstractUser): """authentik User model, based on django's contrib auth user model.""" + # Overwriting PermissionsMixin: permissions are handled by roles. + # (This knowingly violates the Liskov substitution principle. It is better to fail loudly.) + user_permissions = None + uuid = models.UUIDField(default=uuid4, editable=False, unique=True) name = models.TextField(help_text=_("User's display name.")) path = models.TextField(default="users") type = models.TextField(choices=UserTypes.choices, default=UserTypes.INTERNAL) sources = models.ManyToManyField("Source", through="UserSourceConnection") - ak_groups = models.ManyToManyField("Group", related_name="users") + groups = models.ManyToManyField("Group", related_name="users") roles = models.ManyToManyField("authentik_rbac.Role", related_name="users", blank=True) password_change_date = models.DateTimeField(auto_now_add=True) @@ -375,8 +379,6 @@ class User(SerializerModel, AttributesMixin, AbstractUser): permissions = [ ("reset_user_password", _("Reset Password")), ("impersonate", _("Can impersonate other users")), - ("assign_user_permissions", _("Can assign permissions to users")), - ("unassign_user_permissions", _("Can unassign permissions from users")), ("preview_user", _("Can preview user data sent to providers")), ("view_user_applications", _("View applications the user has access to")), ] @@ -400,11 +402,11 @@ class User(SerializerModel, AttributesMixin, AbstractUser): def all_groups(self) -> QuerySet[Group]: """Recursively get all groups this user is a member of.""" - return self.ak_groups.all().with_ancestors() + return self.groups.all().with_ancestors() def all_roles(self) -> QuerySet[Role]: """Get all roles of this user and all of its groups (recursively).""" - return Role.objects.filter(Q(users=self) | Q(ak_groups__in=self.all_groups())).distinct() + return Role.objects.filter(Q(users=self) | Q(groups__in=self.all_groups())).distinct() def get_managed_role(self, create=False): if create: @@ -508,6 +510,42 @@ class User(SerializerModel, AttributesMixin, AbstractUser): """superuser == staff user""" return self.is_superuser # type: ignore + # TODO: remove this after 2026. + @property + def ak_groups(self): + """This is a proxy for a renamed, deprecated field.""" + from authentik.events.models import Event, EventAction + + deprecation = "authentik.core.models.User.ak_groups" + replacement = "authentik.core.models.User.groups" + message_logger = ( + f"{deprecation} is deprecated and will be removed in a future version of " + f"authentik. Please use {replacement} instead." + ) + message_event = ( + f"{message_logger} This event will not be repeated until it expires (by " + "default: in 30 days). See authentik logs for every will invocation of this " + "deprecation." + ) + LOGGER.warning( + "deprecation used", + message=message_logger, + deprecation=deprecation, + replacement=replacement, + ) + if not Event.filter_not_expired( + action=EventAction.CONFIGURATION_WARNING, context__deprecation=deprecation + ).exists(): + event = Event.new( + EventAction.CONFIGURATION_WARNING, + deprecation=deprecation, + replacement=replacement, + message=message_event, + ) + event.expires = datetime.now() + timedelta(days=30) + event.save() + return self.groups + def set_password(self, raw_password, signal=True, sender=None, request=None): if self.pk and signal: from authentik.core.signals import password_changed diff --git a/authentik/core/sources/flow_manager.py b/authentik/core/sources/flow_manager.py index 3830c056db..fc873517b7 100644 --- a/authentik/core/sources/flow_manager.py +++ b/authentik/core/sources/flow_manager.py @@ -392,10 +392,10 @@ class GroupUpdateStage(StageView): groups.append(group) with transaction.atomic(): - self.user.ak_groups.remove( - *self.user.ak_groups.filter(groupsourceconnection__source=self.source) + self.user.groups.remove( + *self.user.groups.filter(groupsourceconnection__source=self.source) ) - self.user.ak_groups.add(*groups) + self.user.groups.add(*groups) return True diff --git a/authentik/core/tests/test_application_entitlements.py b/authentik/core/tests/test_application_entitlements.py index 6685b594d4..8e6ba4bd6b 100644 --- a/authentik/core/tests/test_application_entitlements.py +++ b/authentik/core/tests/test_application_entitlements.py @@ -38,7 +38,7 @@ class TestApplicationEntitlements(APITestCase): def test_group(self): """Test direct group""" group = Group.objects.create(name=generate_id()) - self.user.ak_groups.add(group) + self.user.groups.add(group) ent = ApplicationEntitlement.objects.create(app=self.app, name=generate_id()) PolicyBinding.objects.create(target=ent, group=group, order=0) ents = self.user.app_entitlements(self.app) @@ -50,7 +50,7 @@ class TestApplicationEntitlements(APITestCase): parent = Group.objects.create(name=generate_id()) group = Group.objects.create(name=generate_id()) group.parents.add(parent) - self.user.ak_groups.add(group) + self.user.groups.add(group) ent = ApplicationEntitlement.objects.create(app=self.app, name=generate_id()) PolicyBinding.objects.create(target=ent, group=parent, order=0) ents = self.user.app_entitlements(self.app) diff --git a/authentik/core/tests/test_groups_api.py b/authentik/core/tests/test_groups_api.py index e5ad672a89..dc650628bf 100644 --- a/authentik/core/tests/test_groups_api.py +++ b/authentik/core/tests/test_groups_api.py @@ -122,8 +122,8 @@ class TestGroupsAPI(APITestCase): def test_superuser_update_no_perm(self): """Test updating a superuser group without permission""" group = Group.objects.create(name=generate_id(), is_superuser=True) - self.login_user.assign_perms_to_managed_role("view_group", group) - self.login_user.assign_perms_to_managed_role("change_group", group) + self.login_user.assign_perms_to_managed_role("authentik_core.view_group", group) + self.login_user.assign_perms_to_managed_role("authentik_core.change_group", group) self.client.force_login(self.login_user) res = self.client.patch( reverse("authentik_api:group-detail", kwargs={"pk": group.pk}), @@ -139,8 +139,8 @@ class TestGroupsAPI(APITestCase): """Test updating a superuser group without permission and without changing the superuser status""" group = Group.objects.create(name=generate_id(), is_superuser=True) - self.login_user.assign_perms_to_managed_role("view_group", group) - self.login_user.assign_perms_to_managed_role("change_group", group) + self.login_user.assign_perms_to_managed_role("authentik_core.view_group", group) + self.login_user.assign_perms_to_managed_role("authentik_core.change_group", group) self.client.force_login(self.login_user) res = self.client.patch( reverse("authentik_api:group-detail", kwargs={"pk": group.pk}), diff --git a/authentik/core/tests/test_source_flow_manager_group_update_stage.py b/authentik/core/tests/test_source_flow_manager_group_update_stage.py index edc7d49366..88f814545b 100644 --- a/authentik/core/tests/test_source_flow_manager_group_update_stage.py +++ b/authentik/core/tests/test_source_flow_manager_group_update_stage.py @@ -54,7 +54,7 @@ class TestSourceFlowManager(FlowTestCase): ) self.assertTrue(stage.handle_groups()) self.assertTrue(Group.objects.filter(name="group 1").exists()) - self.assertTrue(self.user.ak_groups.filter(name="group 1").exists()) + self.assertTrue(self.user.groups.filter(name="group 1").exists()) self.assertTrue( GroupOAuthSourceConnection.objects.filter( group=Group.objects.get(name="group 1"), source=self.source @@ -88,7 +88,7 @@ class TestSourceFlowManager(FlowTestCase): ) self.assertTrue(stage.handle_groups()) self.assertTrue(Group.objects.filter(name="group 1").exists()) - self.assertTrue(self.user.ak_groups.filter(name="group 1").exists()) + self.assertTrue(self.user.groups.filter(name="group 1").exists()) self.assertTrue( GroupOAuthSourceConnection.objects.filter( group=Group.objects.get(name="group 1"), source=self.source @@ -123,7 +123,7 @@ class TestSourceFlowManager(FlowTestCase): ) self.assertTrue(stage.handle_groups()) self.assertTrue(Group.objects.filter(name="group 1").exists()) - self.assertTrue(self.user.ak_groups.filter(name="group 1").exists()) + self.assertTrue(self.user.groups.filter(name="group 1").exists()) self.assertTrue( GroupOAuthSourceConnection.objects.filter(group=group, source=self.source).exists() ) @@ -155,7 +155,7 @@ class TestSourceFlowManager(FlowTestCase): ) self.assertTrue(stage.handle_groups()) self.assertTrue(Group.objects.filter(name="group 1").exists()) - self.assertTrue(self.user.ak_groups.filter(name="group 1").exists()) + self.assertTrue(self.user.groups.filter(name="group 1").exists()) self.assertTrue( GroupOAuthSourceConnection.objects.filter( group=Group.objects.get(name="group 1"), source=self.source @@ -189,7 +189,7 @@ class TestSourceFlowManager(FlowTestCase): request=request, ) self.assertFalse(stage.handle_groups()) - self.assertFalse(self.user.ak_groups.filter(name="group 1").exists()) + self.assertFalse(self.user.groups.filter(name="group 1").exists()) self.assertFalse( GroupOAuthSourceConnection.objects.filter(group=group, source=self.source).exists() ) @@ -201,7 +201,7 @@ class TestSourceFlowManager(FlowTestCase): other_group = Group.objects.create(name="other group") old_group = Group.objects.create(name="old group") new_group = Group.objects.create(name="new group") - self.user.ak_groups.set([other_group, old_group]) + self.user.groups.set([other_group, old_group]) GroupOAuthSourceConnection.objects.create( group=old_group, source=self.source, identifier=old_group.name ) @@ -231,7 +231,7 @@ class TestSourceFlowManager(FlowTestCase): request=request, ) self.assertTrue(stage.handle_groups()) - self.assertFalse(self.user.ak_groups.filter(name="old group").exists()) - self.assertTrue(self.user.ak_groups.filter(name="other group").exists()) - self.assertTrue(self.user.ak_groups.filter(name="new group").exists()) - self.assertEqual(self.user.ak_groups.count(), 2) + self.assertFalse(self.user.groups.filter(name="old group").exists()) + self.assertTrue(self.user.groups.filter(name="other group").exists()) + self.assertTrue(self.user.groups.filter(name="new group").exists()) + self.assertEqual(self.user.groups.count(), 2) diff --git a/authentik/core/tests/test_users.py b/authentik/core/tests/test_users.py index 6f73bbe4ea..8900058e48 100644 --- a/authentik/core/tests/test_users.py +++ b/authentik/core/tests/test_users.py @@ -3,6 +3,7 @@ from django.test.testcases import TestCase from authentik.core.models import User +from authentik.events.models import Event from authentik.lib.generators import generate_id @@ -18,3 +19,17 @@ class TestUsers(TestCase): self.assertTrue(user.has_perm(perm)) user.remove_perms_from_managed_role(perm) self.assertFalse(user.has_perm(perm)) + + def test_user_ak_groups(self): + """Test user.ak_groups is a proxy for user.groups""" + user = User.objects.create(username=generate_id()) + self.assertEqual(user.ak_groups, user.groups) + + def test_user_ak_groups_event(self): + """Test user.ak_groups creates exactly one event""" + user = User.objects.create(username=generate_id()) + self.assertEqual(Event.objects.count(), 0) + user.ak_groups.all() + self.assertEqual(Event.objects.count(), 1) + user.ak_groups.all() + self.assertEqual(Event.objects.count(), 1) diff --git a/authentik/crypto/tests.py b/authentik/crypto/tests.py index c6cadaeb96..110aa695d3 100644 --- a/authentik/crypto/tests.py +++ b/authentik/crypto/tests.py @@ -196,8 +196,10 @@ class TestCrypto(APITestCase): """Test certificate export (download)""" keypair = create_test_cert() user = create_test_user() - user.assign_perms_to_managed_role("view_certificatekeypair", keypair) - user.assign_perms_to_managed_role("view_certificatekeypair_certificate", keypair) + user.assign_perms_to_managed_role("authentik_crypto.view_certificatekeypair", keypair) + user.assign_perms_to_managed_role( + "authentik_crypto.view_certificatekeypair_certificate", keypair + ) self.client.force_login(user) response = self.client.get( reverse( @@ -220,8 +222,8 @@ class TestCrypto(APITestCase): """Test private_key export (download)""" keypair = create_test_cert() user = create_test_user() - user.assign_perms_to_managed_role("view_certificatekeypair", keypair) - user.assign_perms_to_managed_role("view_certificatekeypair_key", keypair) + user.assign_perms_to_managed_role("authentik_crypto.view_certificatekeypair", keypair) + user.assign_perms_to_managed_role("authentik_crypto.view_certificatekeypair_key", keypair) self.client.force_login(user) response = self.client.get( reverse( diff --git a/authentik/enterprise/providers/google_workspace/models.py b/authentik/enterprise/providers/google_workspace/models.py index 83726aeafd..ca30dacc05 100644 --- a/authentik/enterprise/providers/google_workspace/models.py +++ b/authentik/enterprise/providers/google_workspace/models.py @@ -145,7 +145,7 @@ class GoogleWorkspaceProvider(OutgoingSyncProvider, BackchannelProvider): type=UserTypes.INTERNAL_SERVICE_ACCOUNT ) if self.filter_group: - base = base.filter(ak_groups__in=[self.filter_group]) + base = base.filter(groups__in=[self.filter_group]) return base.order_by("pk") if type == Group: # Get queryset of all groups with consistent ordering diff --git a/authentik/enterprise/providers/microsoft_entra/models.py b/authentik/enterprise/providers/microsoft_entra/models.py index e7989e7ab5..19eae7eb35 100644 --- a/authentik/enterprise/providers/microsoft_entra/models.py +++ b/authentik/enterprise/providers/microsoft_entra/models.py @@ -134,7 +134,7 @@ class MicrosoftEntraProvider(OutgoingSyncProvider, BackchannelProvider): type=UserTypes.INTERNAL_SERVICE_ACCOUNT ) if self.filter_group: - base = base.filter(ak_groups__in=[self.filter_group]) + base = base.filter(groups__in=[self.filter_group]) return base.order_by("pk") if type == Group: # Get queryset of all groups with consistent ordering diff --git a/authentik/enterprise/providers/ssf/signals.py b/authentik/enterprise/providers/ssf/signals.py index 438e6e671b..0a1ac35c31 100644 --- a/authentik/enterprise/providers/ssf/signals.py +++ b/authentik/enterprise/providers/ssf/signals.py @@ -43,7 +43,7 @@ def ssf_providers_post_save(sender: type[Model], instance: SSFProvider, created: "path": USER_PATH_PROVIDERS_SSF, }, ) - user.assign_perms_to_managed_role("add_stream", instance) + user.assign_perms_to_managed_role("authentik_providers_ssf.add_stream", instance) token, token_created = Token.objects.update_or_create( identifier=identifier, defaults={ diff --git a/authentik/enterprise/reports/serializers.py b/authentik/enterprise/reports/serializers.py index bf81c9ce4d..ca17e41a2d 100644 --- a/authentik/enterprise/reports/serializers.py +++ b/authentik/enterprise/reports/serializers.py @@ -11,7 +11,7 @@ class ExportUserSerializer(UserSerializer): groups = SerializerMethodField(source="get_groups") def get_groups(self, instance: User) -> str: - return ",".join([group.name for group in instance.ak_groups.all()]) + return ",".join([group.name for group in instance.groups.all()]) class Meta(UserSerializer.Meta): fields = [f for f in UserSerializer.Meta.fields if f != "groups_obj"] + ["groups"] diff --git a/authentik/enterprise/stages/mtls/tests/test_stage.py b/authentik/enterprise/stages/mtls/tests/test_stage.py index ca29f3ee50..615f544be4 100644 --- a/authentik/enterprise/stages/mtls/tests/test_stage.py +++ b/authentik/enterprise/stages/mtls/tests/test_stage.py @@ -91,7 +91,9 @@ class MTLSStageTests(FlowTestCase): def test_parse_outpost_object(self): """Test outposts's format""" outpost = Outpost.objects.create(name=generate_id(), type=OutpostType.PROXY) - outpost.user.assign_perms_to_managed_role("pass_outpost_certificate", self.stage) + outpost.user.assign_perms_to_managed_role( + "authentik_stages_mtls.pass_outpost_certificate", self.stage + ) with patch( "authentik.root.middleware.ClientIPMiddleware.get_outpost_user", MagicMock(return_value=outpost.user), diff --git a/authentik/events/migrations/0015_alter_event_action_choices.py b/authentik/events/migrations/0015_alter_event_action_choices.py new file mode 100644 index 0000000000..1c9b25dafb --- /dev/null +++ b/authentik/events/migrations/0015_alter_event_action_choices.py @@ -0,0 +1,50 @@ +# Generated by Django 5.2.10 on 2026-01-20 18:00 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_events", "0014_notification_hyperlink_notification_hyperlink_label_and_more"), + ] + + operations = [ + migrations.AlterField( + model_name="event", + name="action", + field=models.TextField( + choices=[ + ("login", "Login"), + ("login_failed", "Login Failed"), + ("logout", "Logout"), + ("user_write", "User Write"), + ("suspicious_request", "Suspicious Request"), + ("password_set", "Password Set"), + ("secret_view", "Secret View"), + ("secret_rotate", "Secret Rotate"), + ("invitation_used", "Invite Used"), + ("authorize_application", "Authorize Application"), + ("source_linked", "Source Linked"), + ("impersonation_started", "Impersonation Started"), + ("impersonation_ended", "Impersonation Ended"), + ("flow_execution", "Flow Execution"), + ("policy_execution", "Policy Execution"), + ("policy_exception", "Policy Exception"), + ("property_mapping_exception", "Property Mapping Exception"), + ("system_task_execution", "System Task Execution"), + ("system_task_exception", "System Task Exception"), + ("system_exception", "System Exception"), + ("configuration_error", "Configuration Error"), + ("configuration_warning", "Configuration Warning"), + ("model_created", "Model Created"), + ("model_updated", "Model Updated"), + ("model_deleted", "Model Deleted"), + ("email_sent", "Email Sent"), + ("update_available", "Update Available"), + ("export_ready", "Export Ready"), + ("custom_", "Custom Prefix"), + ] + ), + ), + ] diff --git a/authentik/events/models.py b/authentik/events/models.py index cfea806939..e6a19a0263 100644 --- a/authentik/events/models.py +++ b/authentik/events/models.py @@ -112,6 +112,7 @@ class EventAction(models.TextChoices): SYSTEM_EXCEPTION = "system_exception" CONFIGURATION_ERROR = "configuration_error" + CONFIGURATION_WARNING = "configuration_warning" MODEL_CREATED = "model_created" MODEL_UPDATED = "model_updated" diff --git a/authentik/flows/tests/test_executor.py b/authentik/flows/tests/test_executor.py index bfe28e5dc8..117e866758 100644 --- a/authentik/flows/tests/test_executor.py +++ b/authentik/flows/tests/test_executor.py @@ -620,7 +620,7 @@ class TestFlowExecutor(FlowTestCase): user_other = create_test_user() group_a = Group.objects.create(name=generate_id()) - user_group_membership.ak_groups.add(group_a) + user_group_membership.groups.add(group_a) # Stage 0 is an identification stage ident_stage = IdentificationStage.objects.create( diff --git a/authentik/lib/sync/outgoing/signals.py b/authentik/lib/sync/outgoing/signals.py index c73f55cf5b..be02d9a45d 100644 --- a/authentik/lib/sync/outgoing/signals.py +++ b/authentik/lib/sync/outgoing/signals.py @@ -87,4 +87,4 @@ def register_signals( return task_sync_m2m_dispatch.send(instance.pk, action, list(pk_set), reverse) - m2m_changed.connect(model_m2m_changed, User.ak_groups.through, dispatch_uid=uid, weak=False) + m2m_changed.connect(model_m2m_changed, User.groups.through, dispatch_uid=uid, weak=False) diff --git a/authentik/outposts/models.py b/authentik/outposts/models.py index ebc9f74035..3d0258e9cc 100644 --- a/authentik/outposts/models.py +++ b/authentik/outposts/models.py @@ -439,9 +439,13 @@ class Outpost(ScheduledModel, SerializerModel, ManagedModel): if self.managed: for brand in Brand.objects.filter(web_certificate__isnull=False): objects.append(brand) - objects.append(("view_certificatekeypair", brand.web_certificate)) - objects.append(("view_certificatekeypair_certificate", brand.web_certificate)) - objects.append(("view_certificatekeypair_key", brand.web_certificate)) + objects.append(("authentik_crypto.view_certificatekeypair", brand.web_certificate)) + objects.append( + ("authentik_crypto.view_certificatekeypair_certificate", brand.web_certificate) + ) + objects.append( + ("authentik_crypto.view_certificatekeypair_key", brand.web_certificate) + ) return objects def __str__(self) -> str: diff --git a/authentik/policies/event_matcher/migrations/0025_alter_eventmatcherpolicy_action.py b/authentik/policies/event_matcher/migrations/0025_alter_eventmatcherpolicy_action.py new file mode 100644 index 0000000000..7a6c8aede6 --- /dev/null +++ b/authentik/policies/event_matcher/migrations/0025_alter_eventmatcherpolicy_action.py @@ -0,0 +1,53 @@ +# Generated by Django 5.2.10 on 2026-01-20 18:00 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_policies_event_matcher", "0024_alter_eventmatcherpolicy_action"), + ] + + operations = [ + migrations.AlterField( + model_name="eventmatcherpolicy", + name="action", + field=models.TextField( + choices=[ + ("login", "Login"), + ("login_failed", "Login Failed"), + ("logout", "Logout"), + ("user_write", "User Write"), + ("suspicious_request", "Suspicious Request"), + ("password_set", "Password Set"), + ("secret_view", "Secret View"), + ("secret_rotate", "Secret Rotate"), + ("invitation_used", "Invite Used"), + ("authorize_application", "Authorize Application"), + ("source_linked", "Source Linked"), + ("impersonation_started", "Impersonation Started"), + ("impersonation_ended", "Impersonation Ended"), + ("flow_execution", "Flow Execution"), + ("policy_execution", "Policy Execution"), + ("policy_exception", "Policy Exception"), + ("property_mapping_exception", "Property Mapping Exception"), + ("system_task_execution", "System Task Execution"), + ("system_task_exception", "System Task Exception"), + ("system_exception", "System Exception"), + ("configuration_error", "Configuration Error"), + ("configuration_warning", "Configuration Warning"), + ("model_created", "Model Created"), + ("model_updated", "Model Updated"), + ("model_deleted", "Model Deleted"), + ("email_sent", "Email Sent"), + ("update_available", "Update Available"), + ("export_ready", "Export Ready"), + ("custom_", "Custom Prefix"), + ], + default=None, + help_text="Match created events with this action type. When left empty, all action types will be matched.", + null=True, + ), + ), + ] diff --git a/authentik/policies/tests/test_bindings_api.py b/authentik/policies/tests/test_bindings_api.py index f6fb31b10d..bb66387ff2 100644 --- a/authentik/policies/tests/test_bindings_api.py +++ b/authentik/policies/tests/test_bindings_api.py @@ -14,7 +14,7 @@ class TestBindingsAPI(APITestCase): super().setUp() self.pbm = PolicyBindingModel.objects.create() self.user = create_test_admin_user() - self.group = self.user.ak_groups.first() + self.group = self.user.groups.first() self.client.force_login(self.user) def test_valid_binding(self): diff --git a/authentik/providers/ldap/models.py b/authentik/providers/ldap/models.py index 59b4ae2762..6ab65f342d 100644 --- a/authentik/providers/ldap/models.py +++ b/authentik/providers/ldap/models.py @@ -96,9 +96,11 @@ class LDAPProvider(OutpostModel, BackchannelProvider): def get_required_objects(self) -> Iterable[models.Model | str | tuple[str, models.Model]]: required = [self, "authentik_core.view_user", "authentik_core.view_group"] if self.certificate is not None: - required.append(("view_certificatekeypair", self.certificate)) - required.append(("view_certificatekeypair_certificate", self.certificate)) - required.append(("view_certificatekeypair_key", self.certificate)) + required.append(("authentik_crypto.view_certificatekeypair", self.certificate)) + required.append( + ("authentik_crypto.view_certificatekeypair_certificate", self.certificate) + ) + required.append(("authentik_crypto.view_certificatekeypair_key", self.certificate)) return required class Meta: diff --git a/authentik/providers/oauth2/tests/test_userinfo.py b/authentik/providers/oauth2/tests/test_userinfo.py index a3ec867a56..4cc87d88c2 100644 --- a/authentik/providers/oauth2/tests/test_userinfo.py +++ b/authentik/providers/oauth2/tests/test_userinfo.py @@ -66,7 +66,7 @@ class TestUserinfo(OAuthTestCase): "given_name": self.user.name, "preferred_username": self.user.name, "nickname": self.user.name, - "groups": [group.name for group in self.user.ak_groups.all()], + "groups": [group.name for group in self.user.groups.all()], "sub": "bar", }, ) @@ -88,7 +88,7 @@ class TestUserinfo(OAuthTestCase): "given_name": self.user.name, "preferred_username": self.user.name, "nickname": self.user.name, - "groups": [group.name for group in self.user.ak_groups.all()], + "groups": [group.name for group in self.user.groups.all()], "sub": "bar", }, ) diff --git a/authentik/providers/oauth2/views/github.py b/authentik/providers/oauth2/views/github.py index 524899a34f..2ffcd228b1 100644 --- a/authentik/providers/oauth2/views/github.py +++ b/authentik/providers/oauth2/views/github.py @@ -78,7 +78,7 @@ class GitHubUserTeamsView(View): user = token.user orgs_response = [] - for org in user.ak_groups.all(): + for org in user.groups.all(): _org = { "id": org.num_pk, "node_id": "", diff --git a/authentik/providers/proxy/models.py b/authentik/providers/proxy/models.py index a3c606734c..7651bf84b1 100644 --- a/authentik/providers/proxy/models.py +++ b/authentik/providers/proxy/models.py @@ -182,9 +182,11 @@ class ProxyProvider(OutpostModel, OAuth2Provider): def get_required_objects(self) -> Iterable[models.Model | str | tuple[str, models.Model]]: required = [self] if self.certificate is not None: - required.append(("view_certificatekeypair", self.certificate)) - required.append(("view_certificatekeypair_certificate", self.certificate)) - required.append(("view_certificatekeypair_key", self.certificate)) + required.append(("authentik_crypto.view_certificatekeypair", self.certificate)) + required.append( + ("authentik_crypto.view_certificatekeypair_certificate", self.certificate) + ) + required.append(("authentik_crypto.view_certificatekeypair_key", self.certificate)) return required class Meta: diff --git a/authentik/providers/radius/models.py b/authentik/providers/radius/models.py index ed53c3c48a..04ff41bd23 100644 --- a/authentik/providers/radius/models.py +++ b/authentik/providers/radius/models.py @@ -67,9 +67,11 @@ class RadiusProvider(OutpostModel, Provider): def get_required_objects(self) -> Iterable[models.Model | str | tuple[str, models.Model]]: required = [self, "authentik_stages_mtls.pass_outpost_certificate"] if self.certificate is not None: - required.append(("view_certificatekeypair", self.certificate)) - required.append(("view_certificatekeypair_certificate", self.certificate)) - required.append(("view_certificatekeypair_key", self.certificate)) + required.append(("authentik_crypto.view_certificatekeypair", self.certificate)) + required.append( + ("authentik_crypto.view_certificatekeypair_certificate", self.certificate) + ) + required.append(("authentik_crypto.view_certificatekeypair_key", self.certificate)) return required def __str__(self): diff --git a/authentik/rbac/api/rbac.py b/authentik/rbac/api/rbac.py index 960c155659..e670d65add 100644 --- a/authentik/rbac/api/rbac.py +++ b/authentik/rbac/api/rbac.py @@ -19,7 +19,6 @@ from rest_framework.permissions import IsAuthenticated from rest_framework.viewsets import ReadOnlyModelViewSet from authentik.core.api.utils import ModelSerializer, PassiveSerializer -from authentik.core.models import User from authentik.lib.validators import RequiredTogetherValidator from authentik.policies.event_matcher.models import model_choices from authentik.rbac.models import Role @@ -72,7 +71,6 @@ class PermissionFilter(FilterSet): """Filter permissions""" role = ModelChoiceFilter(queryset=Role.objects.all(), method="filter_role") - user = ModelChoiceFilter(queryset=User.objects.all()) def filter_role(self, queryset: QuerySet, name, value: Role) -> QuerySet: """Filter permissions based on role""" @@ -85,7 +83,6 @@ class PermissionFilter(FilterSet): "content_type__model", "content_type__app_label", "role", - "user", ] diff --git a/authentik/rbac/api/roles.py b/authentik/rbac/api/roles.py index c9793df05f..acc9e7bae7 100644 --- a/authentik/rbac/api/roles.py +++ b/authentik/rbac/api/roles.py @@ -73,7 +73,7 @@ class RoleFilterSet(FilterSet): inherited = BooleanFilter( method="filter_inherited", - label="Include inherited roles (requires users or ak_groups filter)", + label="Include inherited roles (requires users or groups filter)", ) users = extend_schema_field(OpenApiTypes.INT)( @@ -83,15 +83,15 @@ class RoleFilterSet(FilterSet): ) ) - ak_groups = extend_schema_field(OpenApiTypes.UUID)( + groups = extend_schema_field(OpenApiTypes.UUID)( CharFilter( - method="filter_ak_groups", + method="filter_groups", label="Filter by group (use with inherited=true for all roles)", ) ) def filter_inherited(self, queryset, name, value): - """This filter is handled by filter_users and filter_ak_groups""" + """This filter is handled by filter_users and filter_groups""" return queryset def filter_users(self, queryset, name, value): @@ -105,7 +105,7 @@ class RoleFilterSet(FilterSet): return user.all_roles() return queryset.filter(users=user) - def filter_ak_groups(self, queryset, name, value): + def filter_groups(self, queryset, name, value): """Filter roles by group, optionally including inherited roles""" group = Group.objects.filter(pk=value).first() if not group: @@ -114,7 +114,7 @@ class RoleFilterSet(FilterSet): include_inherited = self.data.get("inherited", "").lower() == "true" if include_inherited: return group.all_roles() - return queryset.filter(ak_groups=group) + return queryset.filter(groups=group) class Meta: model = Role diff --git a/authentik/rbac/migrations/0010_remove_role_group_alter_role_name.py b/authentik/rbac/migrations/0010_remove_role_group_alter_role_name.py new file mode 100644 index 0000000000..194fa3bc5a --- /dev/null +++ b/authentik/rbac/migrations/0010_remove_role_group_alter_role_name.py @@ -0,0 +1,22 @@ +# Generated by Django 5.2.10 on 2026-01-19 21:42 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_rbac", "0009_remove_initialpermissions_mode"), + ] + + operations = [ + migrations.RemoveField( + model_name="role", + name="group", + ), + migrations.AlterField( + model_name="role", + name="name", + field=models.TextField(unique=True), + ), + ] diff --git a/authentik/rbac/models.py b/authentik/rbac/models.py index 2170a1ab09..1c04c41b9f 100644 --- a/authentik/rbac/models.py +++ b/authentik/rbac/models.py @@ -37,19 +37,7 @@ class Role(SerializerModel, ManagedModel): to it.""" uuid = models.UUIDField(default=uuid4, editable=False, unique=True, primary_key=True) - # Due to the way django and django-guardian work, this is somewhat of a hack. - # Django and django-guardian allow for setting permissions on users and groups, but they - # only allow for a custom user object, not a custom group object, which is why - # we have both authentik and django groups. With this model, we use the inbuilt group system - # for RBAC. This means that every Role needs a single django group that its assigned to - # which will hold all of the actual permissions - # The main advantage of that is that all the permission checking just works out of the box, - # as these permissions are checked by default by django and most other libraries that build - # on top of django - group = models.OneToOneField("auth.Group", on_delete=models.CASCADE, null=True) - - # name field has the same constraints as the group model - name = models.TextField(max_length=150, unique=True) + name = models.TextField(unique=True) @property def serializer(self) -> type[BaseSerializer]: diff --git a/authentik/rbac/tests/test_roles.py b/authentik/rbac/tests/test_roles.py index 523b295cf5..66ea887565 100644 --- a/authentik/rbac/tests/test_roles.py +++ b/authentik/rbac/tests/test_roles.py @@ -32,7 +32,7 @@ class TestRoles(APITestCase): role = Role.objects.create(name=generate_id()) role.assign_perms("authentik_core.view_application") group.roles.add(role) - self.user.ak_groups.add(group) + self.user.groups.add(group) self.assertTrue(self.user.has_perm("authentik_core.view_application")) def test_remove_group_delete(self): @@ -91,7 +91,7 @@ class TestRoles(APITestCase): group.roles.add(role) group.users.add(self.user) self.assertTrue(self.user.has_perm("authentik_core.view_application")) - self.user.ak_groups.remove(group) + self.user.groups.remove(group) user = User.objects.get(username=self.user.username) self.assertFalse(user.has_perm("authentik_core.view_application")) diff --git a/authentik/root/settings.py b/authentik/root/settings.py index b7e96aecad..82ff040351 100644 --- a/authentik/root/settings.py +++ b/authentik/root/settings.py @@ -148,6 +148,7 @@ TENANT_CREATION_FAKES_MIGRATIONS = True TENANT_BASE_SCHEMA = "template" PUBLIC_SCHEMA_NAME = CONFIG.get("postgresql.default_schema") +GUARDIAN_GROUP_MODEL = "authentik_core.Group" GUARDIAN_ROLE_MODEL = "authentik_rbac.Role" SPECTACULAR_SETTINGS = { diff --git a/authentik/sources/kerberos/sync.py b/authentik/sources/kerberos/sync.py index b6a88172a4..5ea9d716fd 100644 --- a/authentik/sources/kerberos/sync.py +++ b/authentik/sources/kerberos/sync.py @@ -113,10 +113,8 @@ class KerberosSync: groups.append(group) with transaction.atomic(): - user.ak_groups.remove( - *user.ak_groups.filter(groupsourceconnection__source=self._source) - ) - user.ak_groups.add(*groups) + user.groups.remove(*user.groups.filter(groupsourceconnection__source=self._source)) + user.groups.add(*groups) except PropertyMappingExpressionException as exc: raise StopSync(exc, None, exc.mapping) from exc diff --git a/authentik/sources/ldap/sync/groups.py b/authentik/sources/ldap/sync/groups.py index 8cc671e458..9382cd4d38 100644 --- a/authentik/sources/ldap/sync/groups.py +++ b/authentik/sources/ldap/sync/groups.py @@ -59,10 +59,10 @@ class GroupLDAPSynchronizer(BaseLDAPSynchronizer): self._task.info("Group syncing is disabled for this Source") return -1 group_count = 0 - for group in page_data: - if (attributes := self.get_attributes(group)) is None: + for group_data in page_data: + if (attributes := self.get_attributes(group_data)) is None: continue - group_dn = flatten(flatten(group.get("entryDN", group.get("dn")))) + group_dn = flatten(flatten(group_data.get("entryDN", group_data.get("dn")))) if not (uniq := self.get_identifier(attributes)): self._task.info( f"Uniqueness field not found/not set in attributes: '{group_dn}'", @@ -88,20 +88,20 @@ class GroupLDAPSynchronizer(BaseLDAPSynchronizer): if "users" in defaults: del defaults["users"] parent = defaults.pop("parent", None) - ak_group, created = Group.update_or_create_attributes( + group, created = Group.update_or_create_attributes( { f"attributes__{LDAP_UNIQUENESS}": uniq, }, defaults, ) if parent: - ak_group.parents.add(parent) + group.parents.add(parent) self._logger.debug("Created group with attributes", **defaults) if not GroupLDAPSourceConnection.objects.filter( source=self._source, identifier=uniq ): GroupLDAPSourceConnection.objects.create( - source=self._source, group=ak_group, identifier=uniq + source=self._source, group=group, identifier=uniq ) except SkipObjectException: continue @@ -119,6 +119,6 @@ class GroupLDAPSynchronizer(BaseLDAPSynchronizer): dn=group_dn, ).save() else: - self._logger.debug("Synced group", group=ak_group.name, created=created) + self._logger.debug("Synced group", group=group.name, created=created) group_count += 1 return group_count diff --git a/authentik/sources/ldap/sync/membership.py b/authentik/sources/ldap/sync/membership.py index 9862a923eb..9fcb3cae6e 100644 --- a/authentik/sources/ldap/sync/membership.py +++ b/authentik/sources/ldap/sync/membership.py @@ -50,9 +50,9 @@ class MembershipLDAPSynchronizer(BaseLDAPSynchronizer): self._task.info("Group syncing is disabled for this Source") return -1 membership_count = 0 - for group in page_data: + for group_data in page_data: if self._source.lookup_groups_from_user: - group_dn = group.get("dn", {}) + group_dn = group_data.get("dn", {}) escaped_dn = escape_filter_chars(group_dn) group_filter = f"({self._source.group_membership_field}={escaped_dn})" group_members = self._source.connection().extend.standard.paged_search( @@ -66,12 +66,12 @@ class MembershipLDAPSynchronizer(BaseLDAPSynchronizer): group_member_dn = group_member.get("dn", {}) members.append(group_member_dn) else: - if (attributes := self.get_attributes(group)) is None: + if (attributes := self.get_attributes(group_data)) is None: continue members = attributes.get(self._source.group_membership_field, []) - ak_group = self.get_group(group) - if not ak_group: + group = self.get_group(group_data) + if not group: continue users = User.objects.filter( @@ -79,14 +79,14 @@ class MembershipLDAPSynchronizer(BaseLDAPSynchronizer): | Q( **{ f"attributes__{self._source.user_membership_attribute}__isnull": True, - "ak_groups__in": [ak_group], + "groups__in": [group], } ) ).distinct() membership_count += 1 membership_count += users.count() - ak_group.users.set(users) - ak_group.save() + group.users.set(users) + group.save() self._logger.debug("Successfully updated group membership") return membership_count diff --git a/authentik/stages/user_write/stage.py b/authentik/stages/user_write/stage.py index 7f2609dbed..e3f95506d1 100644 --- a/authentik/stages/user_write/stage.py +++ b/authentik/stages/user_write/stage.py @@ -186,9 +186,9 @@ class UserWriteStageView(StageView): with transaction.atomic(): user.save() if self.executor.current_stage.create_users_group: - user.ak_groups.add(self.executor.current_stage.create_users_group) + user.groups.add(self.executor.current_stage.create_users_group) if PLAN_CONTEXT_GROUPS in self.executor.plan.context: - user.ak_groups.add(*self.executor.plan.context[PLAN_CONTEXT_GROUPS]) + user.groups.add(*self.executor.plan.context[PLAN_CONTEXT_GROUPS]) except (IntegrityError, ValueError, TypeError, InternalError) as exc: self.logger.warning("Failed to save user", exc=exc) return self.executor.stage_invalid(_("Failed to update user. Please try again later.")) diff --git a/authentik/stages/user_write/tests.py b/authentik/stages/user_write/tests.py index 64cf3e8cde..c4b5a87c0b 100644 --- a/authentik/stages/user_write/tests.py +++ b/authentik/stages/user_write/tests.py @@ -69,7 +69,7 @@ class TestUserWriteStage(FlowTestCase): self.assertTrue(user_qs.exists()) user = user_qs.first() self.assertTrue(user.check_password(password)) - self.assertEqual(list(user.ak_groups.order_by("name")), [self.other_group, self.group]) + self.assertEqual(list(user.groups.order_by("name")), [self.other_group, self.group]) self.assertEqual(user.attributes, {USER_ATTRIBUTE_SOURCES: [self.source.name]}) self.assertTrue( diff --git a/blueprints/default/events-default.yaml b/blueprints/default/events-default.yaml index 205c2b9900..8900fdc695 100644 --- a/blueprints/default/events-default.yaml +++ b/blueprints/default/events-default.yaml @@ -48,6 +48,28 @@ entries: policy: !KeyOf default-match-configuration-error target: !KeyOf default-notify-configuration-error +- model: authentik_policies_event_matcher.eventmatcherpolicy + id: default-match-configuration-warning + attrs: + action: configuration_warning + identifiers: + name: default-match-configuration-warning +- model: authentik_events.notificationrule + id: default-notify-configuration-warning + identifiers: + name: default-notify-configuration-warning + attrs: + severity: warning + group: !KeyOf group + transports: + - !KeyOf default-email-transport + - !KeyOf default-local-transport +- model: authentik_policies.policybinding + identifiers: + order: 0 + policy: !KeyOf default-match-configuration-warning + target: !KeyOf default-notify-configuration-warning + - model: authentik_policies_event_matcher.eventmatcherpolicy id: default-match-update attrs: diff --git a/blueprints/schema.json b/blueprints/schema.json index 815d145999..a090ad6410 100644 --- a/blueprints/schema.json +++ b/blueprints/schema.json @@ -5399,7 +5399,6 @@ "authentik_core.add_user", "authentik_core.add_user_to_group", "authentik_core.add_usersourceconnection", - "authentik_core.assign_user_permissions", "authentik_core.change_application", "authentik_core.change_applicationentitlement", "authentik_core.change_authenticatedsession", @@ -5433,7 +5432,6 @@ "authentik_core.remove_user_from_group", "authentik_core.reset_user_password", "authentik_core.set_token_key", - "authentik_core.unassign_user_permissions", "authentik_core.view_application", "authentik_core.view_applicationentitlement", "authentik_core.view_authenticatedsession", @@ -6084,13 +6082,11 @@ "type": "string", "enum": [ "add_user", - "assign_user_permissions", "change_user", "delete_user", "impersonate", "preview_user", "reset_user_password", - "unassign_user_permissions", "view_user", "view_user_applications" ] @@ -7527,6 +7523,7 @@ "system_task_exception", "system_exception", "configuration_error", + "configuration_warning", "model_created", "model_updated", "model_deleted", @@ -7644,6 +7641,7 @@ "system_task_exception", "system_exception", "configuration_error", + "configuration_warning", "model_created", "model_updated", "model_deleted", @@ -8390,6 +8388,7 @@ "system_task_exception", "system_exception", "configuration_error", + "configuration_warning", "model_created", "model_updated", "model_deleted", @@ -10737,7 +10736,6 @@ "properties": { "name": { "type": "string", - "maxLength": 150, "minLength": 1, "title": "Name" }, @@ -10768,7 +10766,6 @@ "authentik_core.add_user", "authentik_core.add_user_to_group", "authentik_core.add_usersourceconnection", - "authentik_core.assign_user_permissions", "authentik_core.change_application", "authentik_core.change_applicationentitlement", "authentik_core.change_authenticatedsession", @@ -10802,7 +10799,6 @@ "authentik_core.remove_user_from_group", "authentik_core.reset_user_password", "authentik_core.set_token_key", - "authentik_core.unassign_user_permissions", "authentik_core.view_application", "authentik_core.view_applicationentitlement", "authentik_core.view_authenticatedsession", diff --git a/blueprints/system/providers-oauth2.yaml b/blueprints/system/providers-oauth2.yaml index 188a8d165b..1016ff310f 100644 --- a/blueprints/system/providers-oauth2.yaml +++ b/blueprints/system/providers-oauth2.yaml @@ -42,7 +42,7 @@ entries: "given_name": request.user.name, "preferred_username": request.user.username, "nickname": request.user.username, - "groups": [group.name for group in request.user.ak_groups.all()], + "groups": [group.name for group in request.user.groups.all()], } - identifiers: managed: goauthentik.io/providers/oauth2/scope-entitlements diff --git a/blueprints/system/providers-saml.yaml b/blueprints/system/providers-saml.yaml index 8016d6589a..aef73ba8f2 100644 --- a/blueprints/system/providers-saml.yaml +++ b/blueprints/system/providers-saml.yaml @@ -51,7 +51,7 @@ entries: name: "authentik default SAML Mapping: Groups" saml_name: "http://schemas.xmlsoap.org/claims/Group" expression: | - for group in request.user.ak_groups.all(): + for group in request.user.groups.all(): yield group.name - identifiers: managed: goauthentik.io/providers/saml/ms-windowsaccountname diff --git a/blueprints/testing/oidc-conformance.yaml b/blueprints/testing/oidc-conformance.yaml index 7654913613..564bb0000c 100644 --- a/blueprints/testing/oidc-conformance.yaml +++ b/blueprints/testing/oidc-conformance.yaml @@ -45,7 +45,7 @@ entries: "given_name": request.user.name, "preferred_username": request.user.username, "nickname": request.user.username, - "groups": [group.name for group in request.user.ak_groups.all()], + "groups": [group.name for group in request.user.groups.all()], "website" : "foo", "zoneinfo" : "foo", "birthdate" : "2000", diff --git a/packages/ak-guardian/guardian/backends.py b/packages/ak-guardian/guardian/backends.py index 9691f2068b..71d700b7c6 100644 --- a/packages/ak-guardian/guardian/backends.py +++ b/packages/ak-guardian/guardian/backends.py @@ -8,8 +8,8 @@ from django.http import HttpRequest from guardian.conf import settings as guardian_settings from guardian.core import ObjectPermissionChecker -from guardian.ctypes import get_content_type from guardian.exceptions import WrongAppError +from guardian.utils import get_content_type def check_object_support(obj: Model) -> bool: diff --git a/packages/ak-guardian/guardian/conf/settings.py b/packages/ak-guardian/guardian/conf/settings.py index a17f02b6bf..2da47a48c4 100644 --- a/packages/ak-guardian/guardian/conf/settings.py +++ b/packages/ak-guardian/guardian/conf/settings.py @@ -5,27 +5,13 @@ ANONYMOUS_USER_NAME = getattr(settings, "GUARDIAN_ANONYMOUS_USER_NAME", "Anonymo GET_INIT_ANONYMOUS_USER = getattr( settings, "GUARDIAN_GET_INIT_ANONYMOUS_USER", "guardian.management.get_init_anonymous_user" ) -GET_CONTENT_TYPE = getattr( - settings, "GUARDIAN_GET_CONTENT_TYPE", "guardian.ctypes.get_default_content_type" -) # Anonymous user cache TTL configuration # 0 = no cache (default), positive number = cache TTL in seconds, -1 = cache indefinitely ANONYMOUS_USER_CACHE_TTL = getattr(settings, "GUARDIAN_ANONYMOUS_USER_CACHE_TTL", 0) -# Default to using guardian supplied generic object permission models -USER_OBJ_PERMS_MODEL = getattr( - settings, "GUARDIAN_USER_OBJ_PERMS_MODEL", "guardian.UserObjectPermission" -) -GROUP_OBJ_PERMS_MODEL = getattr( - settings, "GUARDIAN_GROUP_OBJ_PERMS_MODEL", "guardian.GroupObjectPermission" -) -ROLE_OBJ_PERMS_MODEL = getattr( - settings, "GUARDIAN_ROLE_OBJ_PERMS_MODEL", "guardian.RoleObjectPermission" -) -# Since get_user_model() causes a circular import if called when app models are -# being loaded, the user_model_label should be used when possible, with calls -# to get_user_model deferred to execution time -user_model_label = getattr(settings, "AUTH_USER_MODEL", "auth.User") +group_model_label = getattr(settings, "GUARDIAN_GROUP_MODEL", None) role_model_label = getattr(settings, "GUARDIAN_ROLE_MODEL", None) +if group_model_label is None: + raise ImproperlyConfigured("ak-guardian requires settings.GUARDIAN_GROUP_MODEL") if role_model_label is None: raise ImproperlyConfigured("ak-guardian requires settings.GUARDIAN_ROLE_MODEL") diff --git a/packages/ak-guardian/guardian/core.py b/packages/ak-guardian/guardian/core.py index 0717264b72..d741e66c85 100644 --- a/packages/ak-guardian/guardian/core.py +++ b/packages/ak-guardian/guardian/core.py @@ -2,8 +2,7 @@ from django.contrib.auth.models import Permission from django.db.models import Model, Q from django.utils.encoding import force_str -from guardian.ctypes import get_content_type -from guardian.utils import get_identity +from guardian.utils import get_content_type, get_identity def remove_app_label(perm: str) -> str: diff --git a/packages/ak-guardian/guardian/ctypes.py b/packages/ak-guardian/guardian/ctypes.py deleted file mode 100644 index ec52913c19..0000000000 --- a/packages/ak-guardian/guardian/ctypes.py +++ /dev/null @@ -1,28 +0,0 @@ -from typing import Any - -from django.contrib.contenttypes.models import ContentType -from django.db.models import Model -from django.utils.module_loading import import_string - -from guardian.conf import settings as guardian_settings - - -def get_content_type(obj: Model | type[Model]) -> Any: - get_content_type_function = import_string(guardian_settings.GET_CONTENT_TYPE) - return get_content_type_function(obj) - - -def get_default_content_type(obj: Model | type[Model]) -> ContentType: - """Get content type for a given object using Django's content type framework. - - Parameters: - obj (Model | Type): Object for which content type is to be fetched. - - Returns: - Content type for the given object. - - See Also: - https://docs.djangoproject.com/en/5.1/ref/contrib/contenttypes/ - - """ - return ContentType.objects.get_for_model(obj) diff --git a/packages/ak-guardian/guardian/exceptions.py b/packages/ak-guardian/guardian/exceptions.py index 7548f0dc19..345f6d6bc9 100644 --- a/packages/ak-guardian/guardian/exceptions.py +++ b/packages/ak-guardian/guardian/exceptions.py @@ -16,12 +16,6 @@ class InvalidIdentity(GuardianError): pass -class ObjectNotPersisted(GuardianError): - """Raised when the object has not been saved to the database.""" - - pass - - class WrongAppError(GuardianError): """Raised when the app name for a permission is incorrect.""" @@ -32,9 +26,3 @@ class MixedContentTypeError(GuardianError): """Raised when content type for the provided permissions and/or class do not match.""" pass - - -class MultipleIdentityAndObjectError(GuardianError): - """Raised when an operation is attempted on both user/group and object.""" - - pass diff --git a/packages/ak-guardian/guardian/managers.py b/packages/ak-guardian/guardian/managers.py deleted file mode 100644 index af713808e9..0000000000 --- a/packages/ak-guardian/guardian/managers.py +++ /dev/null @@ -1,108 +0,0 @@ -from typing import Any - -from django.contrib.auth.models import Permission -from django.db import models -from django.db.models import Model, Q, QuerySet - -from guardian.ctypes import get_content_type -from guardian.exceptions import ObjectNotPersisted - - -class BaseObjectPermissionManager(models.Manager): - def assign_perm(self, perm: str, role: Any, obj: Model) -> Any: - """Assigns permission with given `perm` for an instance `obj` and `role`.""" - if getattr(obj, "pk", None) is None: - raise ObjectNotPersisted(f"Object {obj} needs to be persisted first") - ctype = get_content_type(obj) - if not isinstance(perm, Permission): - permission = Permission.objects.get(content_type=ctype, codename=perm) - else: - permission = perm - - kwargs = { - "permission": permission, - "content_type": ctype, - "object_pk": obj.pk, - "role": role, - } - - obj_perm, _ = self.get_or_create(**kwargs) - return obj_perm - - def assign_perm_to_many( - self, perm: str, roles: Any, obj: Model, ignore_conflicts: bool = False - ) -> Any: - """ - Bulk assigns given `perm` for the object `obj` to a set of roles. - """ - ctype = get_content_type(obj) - if not isinstance(perm, Permission): - permission = Permission.objects.get(content_type=ctype, codename=perm) - else: - permission = perm - - kwargs = { - "permission": permission, - "content_type": ctype, - "object_pk": obj.pk, - } - - to_add = [] - for role in roles: - kwargs["role"] = role - to_add.append(self.model(**kwargs)) - - return self.model.objects.bulk_create(to_add, ignore_conflicts=ignore_conflicts) - - def remove_perm(self, perm: str, role: Any, obj: Model) -> tuple[int, dict]: - """ - Removes permission `perm` for an instance `obj` and given `role`. - - Please note that we do NOT fetch object permission from database - - we use `Queryset.delete` method for removing it. - The main implication of this is that `post_delete` signals would NOT be fired. - """ - if getattr(obj, "pk", None) is None: - raise ObjectNotPersisted(f"Object {obj} needs to be persisted first") - - filters = Q(**{"role": role}) - - if isinstance(perm, Permission): - filters &= Q(permission=perm) - else: - filters &= Q(permission__codename=perm, permission__content_type=get_content_type(obj)) - - filters &= Q(object_pk=obj.pk) - return self.filter(filters).delete() - - def bulk_remove_perm(self, perm: str, role: Any, queryset: QuerySet) -> tuple[int, dict]: - """ - Removes permission `perm` for a `queryset` and given `role`. - - Please note that we do NOT fetch object permission from database - - we use `Queryset.delete` method for removing it. - The main implication of this is that `post_delete` signals would NOT be fired. - """ - filters = Q(**{"role": role}) - - if isinstance(perm, Permission): - filters &= Q(permission=perm) - else: - ctype = get_content_type(queryset.model) - filters &= Q(permission__codename=perm, permission__content_type=ctype) - - filters &= Q(object_pk__in=[str(pk) for pk in queryset.values_list("pk", flat=True)]) - - return self.filter(filters).delete() - - -class UserObjectPermissionManager(BaseObjectPermissionManager): - pass - - -class GroupObjectPermissionManager(BaseObjectPermissionManager): - pass - - -class RoleObjectPermissionManager(BaseObjectPermissionManager): - pass diff --git a/packages/ak-guardian/guardian/migrations/0005_delete_userobjectpermission_and_groupobjectpermission.py b/packages/ak-guardian/guardian/migrations/0005_delete_userobjectpermission_and_groupobjectpermission.py new file mode 100644 index 0000000000..e47f74577a --- /dev/null +++ b/packages/ak-guardian/guardian/migrations/0005_delete_userobjectpermission_and_groupobjectpermission.py @@ -0,0 +1,19 @@ +# Generated by Django 5.2.10 on 2026-01-19 20:50 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("guardian", "0004_role_permissions"), + ] + + operations = [ + migrations.DeleteModel( + name="GroupObjectPermission", + ), + migrations.DeleteModel( + name="UserObjectPermission", + ), + ] diff --git a/packages/ak-guardian/guardian/models.py b/packages/ak-guardian/guardian/models.py index a30da7ea43..6300d673de 100644 --- a/packages/ak-guardian/guardian/models.py +++ b/packages/ak-guardian/guardian/models.py @@ -1,4 +1,4 @@ -from django.contrib.auth.models import Group, Permission +from django.contrib.auth.models import Permission from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError @@ -6,30 +6,25 @@ from django.db import models from django.utils.translation import gettext_lazy as _ from guardian.conf import settings as guardian_settings -from guardian.ctypes import get_content_type -from guardian.managers import ( - GroupObjectPermissionManager, - RoleObjectPermissionManager, - UserObjectPermissionManager, -) +from guardian.utils import get_content_type -class BaseObjectPermission(models.Model): +class RoleObjectPermission(models.Model): + content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE) permission = models.ForeignKey(Permission, on_delete=models.CASCADE) + role = models.ForeignKey(guardian_settings.role_model_label, on_delete=models.CASCADE) + object_pk = models.CharField(_("object ID"), max_length=255) + content_object = GenericForeignKey(fk_field="object_pk") class Meta: - abstract = True + unique_together = ["role", "permission", "object_pk"] + indexes = [ + models.Index(fields=["permission", "role", "content_type", "object_pk"]), + models.Index(fields=["role", "content_type", "object_pk"]), + ] def __str__(self) -> str: - return "{} | {} | {}".format( - str(self.content_object), - str( - getattr(self, "user", False) - or str(getattr(self, "group", False)) - or str(getattr(self, "role", False)) - ), - str(self.permission.codename), - ) + return f"{str(self.content_object)} | {str(self.role)} | {str(self.permission.codename)}" def save(self, *args, **kwargs) -> None: content_type = get_content_type(self.content_object) @@ -41,47 +36,6 @@ class BaseObjectPermission(models.Model): return super().save(*args, **kwargs) -class BaseGenericObjectPermission(models.Model): - content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE) - object_pk = models.CharField(_("object ID"), max_length=255) - content_object = GenericForeignKey(fk_field="object_pk") - - class Meta: - abstract = True - indexes = [ - models.Index(fields=["content_type", "object_pk"]), - ] - - -# The Role* classes follow the User* and Group* class structures for now. -# TODO: restructure Role* classes. - - -class RoleObjectPermissionBase(BaseObjectPermission): - role = models.ForeignKey(guardian_settings.role_model_label, on_delete=models.CASCADE) - - objects = RoleObjectPermissionManager() - - class Meta: - abstract = True - unique_together = ["role", "permission", "content_object"] - - -class RoleObjectPermissionAbstract(RoleObjectPermissionBase, BaseGenericObjectPermission): - class Meta(RoleObjectPermissionBase.Meta, BaseGenericObjectPermission.Meta): - abstract = True - unique_together = ["role", "permission", "object_pk"] - - -class RoleObjectPermission(RoleObjectPermissionAbstract): - class Meta(RoleObjectPermissionAbstract.Meta): - abstract = False - indexes = [ - models.Index(fields=["permission", "role", "content_type", "object_pk"]), - models.Index(fields=["role", "content_type", "object_pk"]), - ] - - class RoleModelPermission(models.Model): content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE) permission = models.ForeignKey(Permission, on_delete=models.CASCADE) @@ -96,57 +50,3 @@ class RoleModelPermission(models.Model): def __str__(self) -> str: return f"RoleModelPermission with role {self.role_id} and permission {self.permission_id}" - - -# The following classes are deprecated and will be removed in a future release. -# TODO: remove deprecated classes. - - -class UserObjectPermissionBase(BaseObjectPermission): - user = models.ForeignKey(guardian_settings.user_model_label, on_delete=models.CASCADE) - - objects = UserObjectPermissionManager() - - class Meta: - abstract = True - unique_together = ["user", "permission", "content_object"] - - -class UserObjectPermissionAbstract(UserObjectPermissionBase, BaseGenericObjectPermission): - class Meta(UserObjectPermissionBase.Meta, BaseGenericObjectPermission.Meta): - abstract = True - unique_together = ["user", "permission", "object_pk"] - - -class UserObjectPermission(UserObjectPermissionAbstract): - class Meta(UserObjectPermissionAbstract.Meta): - abstract = False - indexes = [ - models.Index(fields=["permission", "user", "content_type", "object_pk"]), - models.Index(fields=["user", "content_type", "object_pk"]), - ] - - -class GroupObjectPermissionBase(BaseObjectPermission): - group = models.ForeignKey(Group, on_delete=models.CASCADE) - - objects = GroupObjectPermissionManager() - - class Meta: - abstract = True - unique_together = ["group", "permission", "content_object"] - - -class GroupObjectPermissionAbstract(GroupObjectPermissionBase, BaseGenericObjectPermission): - class Meta(GroupObjectPermissionBase.Meta, BaseGenericObjectPermission.Meta): - abstract = True - unique_together = ["group", "permission", "object_pk"] - - -class GroupObjectPermission(GroupObjectPermissionAbstract): - class Meta(GroupObjectPermissionAbstract.Meta): - abstract = False - indexes = [ - models.Index(fields=["permission", "group", "content_type", "object_pk"]), - models.Index(fields=["group", "content_type", "object_pk"]), - ] diff --git a/packages/ak-guardian/guardian/shortcuts.py b/packages/ak-guardian/guardian/shortcuts.py index 14cf18bef2..944825c1c3 100644 --- a/packages/ak-guardian/guardian/shortcuts.py +++ b/packages/ak-guardian/guardian/shortcuts.py @@ -15,7 +15,6 @@ from django.db.models import ( from django.db.models.expressions import RawSQL from guardian.core import ObjectPermissionChecker -from guardian.ctypes import get_content_type from guardian.exceptions import ( GuardianError, InvalidIdentity, @@ -23,6 +22,7 @@ from guardian.exceptions import ( ) from guardian.utils import ( get_anonymous_user, + get_content_type, get_identity, get_role_model_perms_model, get_role_obj_perms_model, @@ -46,7 +46,7 @@ def assign_perm( perm: str | Permission, role: Any, obj: Model | None = None, -) -> str | Permission | None: +): """Assigns permission to role and object pair. Parameters: @@ -67,50 +67,37 @@ def assign_perm( if not role: return None - # If obj is None we try to operate on global permissions - if obj is None: - if not isinstance(perm, Permission): - try: - app_label, codename = perm.split(".", 1) - except ValueError: - raise ValueError( - "For global permissions, first argument must be in format: " - f"'app_label.codename' (is {perm})" - ) from None - permission = Permission.objects.get( - content_type__app_label=app_label, codename=codename - ) - else: - permission = perm + if not isinstance(perm, Permission): + try: + app_label, codename = perm.split(".", 1) + except ValueError: + raise ValueError( + "For global permissions, first argument must be in format: " + f"'app_label.codename' (is {perm})" + ) from None + permission = Permission.objects.get(content_type__app_label=app_label, codename=codename) + else: + permission = perm - kwargs = { - "content_type": permission.content_type, - "permission": permission, - "role": role, - } + kwargs = { + "content_type": permission.content_type, + "permission": permission, + "role": role, + } + if obj is None: model_perm, _ = get_role_model_perms_model().objects.get_or_create(**kwargs) return model_perm - - if not isinstance(perm, Permission): - if "." in perm: - app_label, perm = perm.split(".", 1) - - if isinstance(obj, QuerySet | list): - raise RuntimeError("Currently not supported") - - if isinstance(role, QuerySet | list): - model = get_role_obj_perms_model(obj) - return model.objects.assign_perm_to_many(perm, role, obj) - - model = get_role_obj_perms_model(obj) - return model.objects.assign_perm(perm, role, obj) + else: + kwargs["object_pk"] = obj.pk + obj_perm, _ = get_role_obj_perms_model().objects.get_or_create(**kwargs) + return obj_perm def remove_perm( perm: str | Permission, role: Any, - obj: Model | QuerySet | None = None, -) -> None: + obj: Model | None = None, +): """Removes permission from role and object pair. Parameters: @@ -128,40 +115,30 @@ def remove_perm( if not role: return None + if not isinstance(perm, Permission): + try: + app_label, codename = perm.split(".", 1) + except ValueError: + raise ValueError( + "For global permissions, first argument must be in format: " + f"'app_label.codename' (is {perm})" + ) from None + permission = Permission.objects.get(content_type__app_label=app_label, codename=codename) + else: + permission = perm + + kwargs = { + "content_type": permission.content_type, + "permission": permission, + "role": role, + } if obj is None: - if not isinstance(perm, Permission): - try: - app_label, codename = perm.split(".", 1) - except ValueError: - raise ValueError( - "For global permissions, first argument must be in format: " - f"'app_label.codename' (is {perm})" - ) from None - permission = Permission.objects.get( - content_type__app_label=app_label, codename=codename - ) - else: - permission = perm - - kwargs = { - "content_type": permission.content_type, - "permission": permission, - "role": role, - } - model_perm = get_role_model_perms_model().objects.filter(**kwargs).delete() return model_perm - - if not isinstance(perm, Permission): - if "." in perm: - app_label, perm = perm.split(".", 1) - perm = perm.split(".")[-1] - - if isinstance(obj, QuerySet): - raise RuntimeError("Currently not supported") - - model = get_role_obj_perms_model(obj) - return model.objects.remove_perm(perm, role, obj) + else: + kwargs["object_pk"] = obj.pk + obj_perm = get_role_obj_perms_model().objects.filter(**kwargs).delete() + return obj_perm def get_perms(identity: Any, obj: Model | None = None) -> set[str]: @@ -272,7 +249,7 @@ def get_objects_for_user( # noqa: PLR0912 PLR0915 return queryset # Now we should extract the list of pk values for which we would filter the queryset - role_model = get_role_obj_perms_model(queryset.model) + role_model = get_role_obj_perms_model() perms_queryset = ( role_model.objects.filter(role__in=user.all_roles()) .filter(permission__content_type=ctype) diff --git a/packages/ak-guardian/guardian/utils.py b/packages/ak-guardian/guardian/utils.py index 2741231d12..5b59ad9477 100644 --- a/packages/ak-guardian/guardian/utils.py +++ b/packages/ak-guardian/guardian/utils.py @@ -11,11 +11,11 @@ import time from math import ceil from typing import Any -from django.apps import apps as django_apps +from django.apps import apps from django.contrib.auth import get_user_model from django.contrib.auth.models import AnonymousUser +from django.contrib.contenttypes.models import ContentType from django.core.cache import cache -from django.core.exceptions import ImproperlyConfigured from django.db.models import Model, QuerySet from guardian.conf import settings as guardian_settings @@ -24,6 +24,32 @@ from guardian.exceptions import InvalidIdentity logger = logging.getLogger(__name__) +def get_content_type(obj: Model | type[Model]) -> ContentType: + return ContentType.objects.get_for_model(obj) + + +def get_role_obj_perms_model() -> type[Model]: + from guardian.models import RoleObjectPermission + + return RoleObjectPermission + + +def get_role_model_perms_model() -> type[Model]: + from guardian.models import RoleModelPermission + + return RoleModelPermission + + +def get_group_model() -> type[Model]: + app_name, model_name = guardian_settings.group_model_label.split(".", 1) + return apps.get_model(app_name, model_name) + + +def get_role_model() -> type[Model]: + app_name, model_name = guardian_settings.role_model_label.split(".", 1) + return apps.get_model(app_name, model_name) + + def _get_anonymous_user_cached() -> Any: """Internal cached version of get_anonymous_user using Django's cache system.""" cache_key = f"guardian:anonymous_user:{guardian_settings.ANONYMOUS_USER_NAME}" @@ -102,13 +128,14 @@ def get_identity(identity: Model) -> tuple[Any | None, Any | None, Any | None]: if isinstance(identity, AnonymousUser): identity = get_anonymous_user() - group_model = get_group_obj_perms_model().group.field.related_model - role_model = get_role_obj_perms_model().role.field.related_model + user_model = get_user_model() + group_model = get_group_model() + role_model = get_role_model() # get identity from queryset model type if isinstance(identity, QuerySet): identity_model_type = identity.model - if identity_model_type == get_user_model(): + if identity_model_type == user_model: return identity, None, None elif identity_model_type == group_model: return None, identity, None @@ -116,14 +143,14 @@ def get_identity(identity: Model) -> tuple[Any | None, Any | None, Any | None]: return None, None, identity # get identity from the first element in the list - if isinstance(identity, list) and isinstance(identity[0], get_user_model()): + if isinstance(identity, list) and isinstance(identity[0], user_model): return identity, None, None if isinstance(identity, list) and isinstance(identity[0], group_model): return None, identity, None if isinstance(identity, list) and isinstance(identity[0], role_model): return None, None, identity - if isinstance(identity, get_user_model()): + if isinstance(identity, user_model): return identity, None, None if isinstance(identity, group_model): return None, identity, None @@ -135,101 +162,6 @@ def get_identity(identity: Model) -> tuple[Any | None, Any | None, Any | None]: ) -def get_obj_perm_model_by_conf(setting_name: str) -> type[Model]: - """Return the model that matches the guardian settings. - - Parameters: - setting_name (str): The name of the setting to get the model from. - - Returns: - The model class that matches the guardian settings. - - Raises: - ImproperlyConfigured: If the setting value is not an installed model or - does not follow the format 'app_label.model_name'. - """ - setting_value: str = getattr(guardian_settings, setting_name) - try: - return django_apps.get_model(setting_value, require_ready=False) # type: ignore - except ValueError as e: - raise ImproperlyConfigured( - f"{setting_value} must be of the form 'app_label.model_name'" - ) from e - except LookupError as e: - raise ImproperlyConfigured( - f"{setting_name} refers to model '{setting_value}' that has not been installed" - ) from e - - -def get_obj_perms_model( - obj: Model | None, base_cls: type[Model], generic_cls: type[Model] -) -> type[Model]: - """Return the matching object permission model for the obj class. - - Defaults to returning the generic object permission when no direct foreignkey is defined, or - obj is None. - """ - # Default to the generic object permission model - # when None obj is provided - if obj is None: - return generic_cls - - if isinstance(obj, Model): - obj = obj.__class__ - - return generic_cls - - -def get_user_obj_perms_model(obj: Model | None = None) -> type[Model]: - """Returns model class that connects given `obj` and User class. - - If obj is not specified, then the user generic object permission model - that is returned is determined by the guardian settings for 'USER_OBJ_PERMS_MODEL'. - """ - from guardian.models import UserObjectPermissionBase - - UserObjectPermission = get_obj_perm_model_by_conf("USER_OBJ_PERMS_MODEL") - return get_obj_perms_model(obj, UserObjectPermissionBase, UserObjectPermission) - - -def get_group_obj_perms_model(obj: Model | None = None) -> type[Model]: - """Returns model class that connects given `obj` and Group class. - - If obj is not specified, then the group generic object permission model - that is returned is determined by the guardian settings for 'GROUP_OBJ_PERMS_MODEL'. - """ - from guardian.models import GroupObjectPermissionBase - - GroupObjectPermission = get_obj_perm_model_by_conf("GROUP_OBJ_PERMS_MODEL") - return get_obj_perms_model(obj, GroupObjectPermissionBase, GroupObjectPermission) - - -def get_role_obj_perms_model(obj: Model | None = None) -> type[Model]: - """Returns model class that connects given `obj` and Role class. - - If obj is not specified, then the role generic object permission model - that is returned is determined by the guardian settings for 'ROLE_OBJ_PERMS_MODEL'. - """ - from guardian.models import RoleObjectPermissionBase - - RoleObjectPermission = get_obj_perm_model_by_conf("ROLE_OBJ_PERMS_MODEL") - return get_obj_perms_model(obj, RoleObjectPermissionBase, RoleObjectPermission) - - -def get_role_model_perms_model() -> type[Model]: - """Returns model class that connects the given Role class.""" - from guardian.models import RoleModelPermission - - return RoleModelPermission - - -def evict_obj_perms_cache(obj: Any) -> bool: - if hasattr(obj, "_guardian_perms_cache"): - delattr(obj, "_guardian_perms_cache") - return True - return False - - def clean_orphan_obj_perms( # noqa: PLR0915 batch_size: int | None = None, max_batches: int | None = None, diff --git a/schema.yml b/schema.yml index 95e9fec9dc..601450f221 100644 --- a/schema.yml +++ b/schema.yml @@ -6908,6 +6908,7 @@ paths: enum: - authorize_application - configuration_error + - configuration_warning - custom_ - email_sent - export_ready @@ -7169,6 +7170,7 @@ paths: enum: - authorize_application - configuration_error + - configuration_warning - custom_ - email_sent - export_ready @@ -7296,6 +7298,7 @@ paths: enum: - authorize_application - configuration_error + - configuration_warning - custom_ - email_sent - export_ready @@ -10956,6 +10959,7 @@ paths: enum: - authorize_application - configuration_error + - configuration_warning - custom_ - email_sent - export_ready @@ -20449,10 +20453,6 @@ paths: schema: type: string - $ref: '#/components/parameters/QuerySearch' - - in: query - name: user - schema: - type: integer tags: - rbac security: @@ -20747,7 +20747,7 @@ paths: description: Role viewset parameters: - in: query - name: ak_groups + name: groups schema: type: string format: uuid @@ -20755,7 +20755,7 @@ paths: name: inherited schema: type: boolean - description: Include inherited roles (requires users or ak_groups filter) + description: Include inherited roles (requires users or groups filter) - in: query name: managed schema: @@ -38110,6 +38110,7 @@ components: - system_task_exception - system_exception - configuration_error + - configuration_warning - model_created - model_updated - model_deleted @@ -49173,7 +49174,6 @@ components: name: type: string minLength: 1 - maxLength: 150 PatchedSAMLPropertyMappingRequest: type: object description: SAMLPropertyMapping Serializer @@ -52369,7 +52369,6 @@ components: title: Uuid name: type: string - maxLength: 150 required: - name - pk @@ -52458,7 +52457,6 @@ components: name: type: string minLength: 1 - maxLength: 150 required: - name SAMLBindingsEnum: diff --git a/tests/e2e/test_provider_ldap.py b/tests/e2e/test_provider_ldap.py index 69878142d2..027e532cb7 100644 --- a/tests/e2e/test_provider_ldap.py +++ b/tests/e2e/test_provider_ldap.py @@ -43,7 +43,9 @@ class TestProviderLDAP(SeleniumTestCase): authorization_flow=Flow.objects.get(slug="default-authentication-flow"), search_mode=APIAccessMode.CACHED, ) - self.user.assign_perms_to_managed_role("search_full_directory", ldap) + self.user.assign_perms_to_managed_role( + "authentik_providers_ldap.search_full_directory", ldap + ) # we need to create an application to actually access the ldap Application.objects.create(name=generate_id(), slug=generate_id(), provider=ldap) outpost: Outpost = Outpost.objects.create( @@ -300,7 +302,7 @@ class TestProviderLDAP(SeleniumTestCase): "gidNumber": 2000 + self.user.pk, "memberOf": [ f"cn={group.name},ou=groups,dc=ldap,dc=goauthentik,dc=io" - for group in self.user.ak_groups.all() + for group in self.user.groups.all() ], "homeDirectory": f"/home/{self.user.username}", "ak-active": True, @@ -381,7 +383,7 @@ class TestProviderLDAP(SeleniumTestCase): "gidNumber": 2000 + user.pk, "memberOf": [ f"cn={group.name},ou=groups,dc=ldap,dc=goauthentik,dc=io" - for group in user.ak_groups.all() + for group in user.groups.all() ], "homeDirectory": f"/home/{user.username}", "ak-active": True, diff --git a/tests/e2e/test_source_ldap_samba.py b/tests/e2e/test_source_ldap_samba.py index 0ec40a1f86..a8b68886bc 100644 --- a/tests/e2e/test_source_ldap_samba.py +++ b/tests/e2e/test_source_ldap_samba.py @@ -103,14 +103,14 @@ class TestSourceLDAPSamba(SeleniumTestCase): self.assertIsNotNone(User.objects.get(username="harry")) self.assertIsNotNone(Group.objects.get(name="dev")) self.assertEqual( - list(User.objects.get(username="bob").ak_groups.all()), [Group.objects.get(name="dev")] + list(User.objects.get(username="bob").groups.all()), [Group.objects.get(name="dev")] ) - self.assertEqual(list(User.objects.get(username="james").ak_groups.all()), []) + self.assertEqual(list(User.objects.get(username="james").groups.all()), []) self.assertEqual( - list(User.objects.get(username="john").ak_groups.all().order_by("name")), + list(User.objects.get(username="john").groups.all().order_by("name")), [Group.objects.get(name="admins"), Group.objects.get(name="dev")], ) - self.assertEqual(list(User.objects.get(username="harry").ak_groups.all()), []) + self.assertEqual(list(User.objects.get(username="harry").groups.all()), []) @retry(exceptions=[LDAPSessionTerminatedByServerError]) @apply_blueprint( diff --git a/web/src/admin/roles/RelatedRoleList.ts b/web/src/admin/roles/RelatedRoleList.ts index 61089f149f..ca8c4dff93 100644 --- a/web/src/admin/roles/RelatedRoleList.ts +++ b/web/src/admin/roles/RelatedRoleList.ts @@ -120,7 +120,7 @@ export class RelatedRoleList extends Table { if (this.targetGroup) { return this.#api.rbacRolesList({ ...config, - akGroups: this.targetGroup.pk, + groups: this.targetGroup.pk, inherited: this.showInherited, }); } diff --git a/website/docs/releases/2026/v2026.2.md b/website/docs/releases/2026/v2026.2.md index 6dabb99e06..95fdbe76ed 100644 --- a/website/docs/releases/2026/v2026.2.md +++ b/website/docs/releases/2026/v2026.2.md @@ -12,7 +12,11 @@ To try out the release candidate, replace your Docker image tag with the latest ## Highlights - +## Breaking changes + +### RBAC + +`User.ak_groups` has been deprecated. Users' groups are now accessed through `User.groups`. Usage of `.ak_groups` will continue to function, but will create a configuration warning event. We recommend you check any custom code (e.g. expression policies, property mappings) that deals with group memberships to update them if necessary. ## New features and improvements diff --git a/website/docs/users-sources/sources/property-mappings/index.md b/website/docs/users-sources/sources/property-mappings/index.md index acdb840602..85ed0b789e 100644 --- a/website/docs/users-sources/sources/property-mappings/index.md +++ b/website/docs/users-sources/sources/property-mappings/index.md @@ -36,7 +36,7 @@ return { } ``` -You can see that the expression returns a Python dictionary. The dictionary keys must match [User properties](../../user/user_ref.mdx#object-properties) or [Group properties](../../groups/group_ref.md#object-properties). Note that for users, `ak_groups` and `group_attributes` cannot be set. +You can see that the expression returns a Python dictionary. The dictionary keys must match [User properties](../../user/user_ref.mdx#object-properties) or [Group properties](../../groups/group_ref.md#object-properties). Note that for users, `groups` and `group_attributes` cannot be set. See each source documentation for a reference of the available data. See the authentik [expressions documentation](./expressions.md) for available data and functions. diff --git a/website/docs/users-sources/user/user_ref.mdx b/website/docs/users-sources/user/user_ref.mdx index ac3468e64e..6c33a73bd1 100644 --- a/website/docs/users-sources/user/user_ref.mdx +++ b/website/docs/users-sources/user/user_ref.mdx @@ -17,7 +17,7 @@ The User object has the following properties: - `path`: User's path, see [Path](#path) - `attributes`: Dynamic attributes, see [Attributes](#attributes) - `group_attributes()`: Merged attributes of all groups the user is a member of and the user's own attributes. Read-only. -- `ak_groups`: This is a queryset of all the user's direct groups. +- `groups`: This is a queryset of all the user's direct groups. - `all_groups()`: This is a queryset of all the user's direct and indirect groups. :::info Clarifying direct vs indirect group membership @@ -33,7 +33,7 @@ These are examples of how User objects can be used within Policies and Property Use the following example to list all groups that a user object is a direct member of: ```python -for group in user.ak_groups.all(): +for group in user.groups.all(): yield group.name ``` @@ -42,7 +42,7 @@ for group in user.ak_groups.all(): Use the following example to list groups that a user object is a direct member of, filtered based on group name: ```python -user.ak_groups.filter(name__startswith='test') +user.groups.filter(name__startswith='test') ``` ### List a user's group memberships including parent groups