rbac: clean up roles and permissions (#19588)

* clean up roles and permissions

This was purposefully not included in `2025.12` to split the changes up.

The main content of this patch is in the migrations. Everything else
follows more or less automatically.

* add breaking change warning to release notes

* add `ak_groups` --> `groups` deprecated proxy

* fixup! add `ak_groups` --> `groups` deprecated proxy

* fixup! add `ak_groups` --> `groups` deprecated proxy

* fixup! add `ak_groups` --> `groups` deprecated proxy

* add configuration warning to default notifications blueprint

* add rudimentary tests for User.ak_groups

* remove no longer used permissions

* clarify deprecation

Co-authored-by: Jens L. <jens@goauthentik.io>
Signed-off-by: Simonyi Gergő <28359278+gergosimonyi@users.noreply.github.com>

* remove integration changes

These will be included in a separate PR once this is released.

---------

Signed-off-by: Simonyi Gergő <28359278+gergosimonyi@users.noreply.github.com>
Co-authored-by: Jens L. <jens@goauthentik.io>
This commit is contained in:
Simonyi Gergő
2026-01-29 19:12:38 +01:00
committed by GitHub
parent 387a3ef08d
commit 1b9653901c
62 changed files with 508 additions and 599 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -120,7 +120,7 @@ export class RelatedRoleList extends Table<Role> {
if (this.targetGroup) {
return this.#api.rbacRolesList({
...config,
akGroups: this.targetGroup.pk,
groups: this.targetGroup.pk,
inherited: this.showInherited,
});
}

View File

@@ -12,7 +12,11 @@ To try out the release candidate, replace your Docker image tag with the latest
## Highlights
<!-- ## Breaking changes -->
## 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

View File

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

View File

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