core: add digraph group hierarchy (#17050)

* move imports

* core: add digraph group hierarchy

* move to permissions from Group or User to Role

* set group parents on frontend

* do not serialize `GroupParentageNode` directly

* core: enforce unique group name on database level

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* use group parents in LDAP provider

* add user-role relationship control to frontend

* move materialized view to be more discoverable

* add guardian to mypy exceptions

* make `Role` a `ManagedModel`

* fixup! make `Role` a `ManagedModel`

* simplify `get_objects_for_user`

* fix flaky unit test

* rename `django-guardian` fork to `ak-guardian`

* add tests around users/groups/roles

* remove unused guardian config variable

* simplify guardian file structure

* clean up frontend

* initial docs

* remove `mode` from `InitialPermissions`

This is no longer needed, since users no longer directly have permissions.

* fixup! Merge branch 'main' into core/add-digraph-group-hierarchy

* clean up docs for managing permissions

* addendums from docs review

* fixup! Merge branch 'main' into core/add-digraph-group-hierarchy

* tweaks

* dewi and tana edits to docs

* tweak

* truly final tweaks, for now

* relabel Role Permissions table

* clarify button label

* fixup! Merge branch 'main' into core/add-digraph-group-hierarchy

* fixup! Merge branch 'main' into core/add-digraph-group-hierarchy

* merge migrations

* fixup! Merge branch 'main' into core/add-digraph-group-hierarchy

---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Tana M Berry <tana@goauthentik.io>
This commit is contained in:
Simonyi Gergő
2025-12-08 12:04:04 +01:00
committed by GitHub
parent d54409c5dd
commit f7e23295ed
159 changed files with 4511 additions and 2849 deletions

View File

@@ -70,6 +70,9 @@ class IPCUser(AnonymousUser):
def is_authenticated(self):
return True
def all_roles(self):
return []
class TokenAuthentication(BaseAuthentication):
"""Token-based authentication using HTTP Bearer authentication"""

View File

@@ -36,10 +36,7 @@ class TestBlueprintsV1RBAC(TransactionTestCase):
self.assertTrue(importer.apply())
role = Role.objects.filter(name=uid).first()
self.assertIsNotNone(role)
self.assertEqual(
list(role.group.permissions.all().values_list("codename", flat=True)),
["view_blueprintinstance"],
)
self.assertEqual(get_perms(role), {"authentik_blueprints.view_blueprintinstance"})
def test_object_permission(self):
"""Test permissions"""
@@ -53,5 +50,5 @@ class TestBlueprintsV1RBAC(TransactionTestCase):
user = User.objects.filter(username=uid).first()
role = Role.objects.filter(name=uid).first()
self.assertIsNotNone(flow)
self.assertEqual(get_perms(user, flow), ["view_flow"])
self.assertEqual(get_perms(role.group, flow), ["view_flow"])
self.assertEqual(get_perms(user, flow), {"authentik_flows.view_flow"})
self.assertEqual(get_perms(role, flow), {"authentik_flows.view_flow"})

View File

@@ -16,8 +16,7 @@ from django.db.models.query_utils import Q
from django.db.transaction import atomic
from django.db.utils import IntegrityError
from django_channels_postgres.models import GroupChannel, Message
from guardian.models import UserObjectPermission
from guardian.shortcuts import assign_perm
from guardian.models import RoleObjectPermission, UserObjectPermission
from rest_framework.exceptions import ValidationError
from rest_framework.serializers import BaseSerializer, Serializer
from structlog.stdlib import BoundLogger, get_logger
@@ -110,6 +109,7 @@ def excluded_models() -> list[type[Model]]:
DjangoGroup,
ContentType,
Permission,
RoleObjectPermission,
UserObjectPermission,
# Base classes
Provider,
@@ -394,10 +394,12 @@ class Importer:
"""Apply object-level permissions for an entry"""
for perm in entry.get_permissions(self._import):
if perm.user is not None:
assign_perm(perm.permission, User.objects.get(pk=perm.user), instance)
User.objects.get(pk=perm.user).assign_perms_to_managed_role(
perm.permission, instance
)
if perm.role is not None:
role = Role.objects.get(pk=perm.role)
role.assign_permission(perm.permission, obj=instance)
role.assign_perms(perm.permission, obj=instance)
def apply(self) -> bool:
"""Apply (create/update) models yaml, in database transaction"""

View File

@@ -78,7 +78,7 @@ class AdminDeviceViewSet(ViewSet):
"""Get all devices in all child classes"""
for model in device_classes():
device_set = get_objects_for_user(
self.request.user, f"{model._meta.app_label}.view_{model._meta.model_name}", model
self.request.user, f"{model._meta.app_label}.view_{model._meta.model_name}"
).filter(**kwargs)
yield from device_set

View File

@@ -18,10 +18,10 @@ from rest_framework.authentication import SessionAuthentication
from rest_framework.decorators import action
from rest_framework.fields import CharField, IntegerField, SerializerMethodField
from rest_framework.permissions import IsAuthenticated
from rest_framework.relations import PrimaryKeyRelatedField
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.serializers import ListSerializer, ValidationError
from rest_framework.validators import UniqueValidator
from rest_framework.viewsets import ModelViewSet
from authentik.api.authentication import TokenAuthentication
@@ -54,8 +54,8 @@ class PartialUserSerializer(ModelSerializer):
]
class GroupChildSerializer(ModelSerializer):
"""Stripped down group serializer to show relevant children for groups"""
class RelatedGroupSerializer(ModelSerializer):
"""Stripped down group serializer to show relevant children/parents for groups"""
attributes = JSONDictField(required=False)
@@ -74,15 +74,16 @@ class GroupSerializer(ModelSerializer):
"""Group Serializer"""
attributes = JSONDictField(required=False)
users_obj = SerializerMethodField(allow_null=True)
parents = PrimaryKeyRelatedField(queryset=Group.objects.all(), many=True, required=False)
parents_obj = SerializerMethodField(allow_null=True)
children_obj = SerializerMethodField(allow_null=True)
users_obj = SerializerMethodField(allow_null=True)
roles_obj = ListSerializer(
child=RoleSerializer(),
read_only=True,
source="roles",
required=False,
)
parent_name = CharField(source="parent.name", read_only=True, allow_null=True)
num_pk = IntegerField(read_only=True)
@property
@@ -99,25 +100,30 @@ class GroupSerializer(ModelSerializer):
return True
return str(request.query_params.get("include_children", "false")).lower() == "true"
@property
def _should_include_parents(self) -> bool:
request: Request = self.context.get("request", None)
if not request:
return True
return str(request.query_params.get("include_parents", "false")).lower() == "true"
@extend_schema_field(PartialUserSerializer(many=True))
def get_users_obj(self, instance: Group) -> list[PartialUserSerializer] | None:
if not self._should_include_users:
return None
return PartialUserSerializer(instance.users, many=True).data
@extend_schema_field(GroupChildSerializer(many=True))
def get_children_obj(self, instance: Group) -> list[GroupChildSerializer] | None:
@extend_schema_field(RelatedGroupSerializer(many=True))
def get_children_obj(self, instance: Group) -> list[RelatedGroupSerializer] | None:
if not self._should_include_children:
return None
return GroupChildSerializer(instance.children, many=True).data
return RelatedGroupSerializer(instance.children, many=True).data
def validate_parent(self, parent: Group | None):
"""Validate group parent (if set), ensuring the parent isn't itself"""
if not self.instance or not parent:
return parent
if str(parent.group_uuid) == str(self.instance.group_uuid):
raise ValidationError(_("Cannot set group as parent of itself."))
return parent
@extend_schema_field(RelatedGroupSerializer(many=True))
def get_parents_obj(self, instance: Group) -> list[RelatedGroupSerializer] | None:
if not self._should_include_parents:
return None
return RelatedGroupSerializer(instance.parents, many=True).data
def validate_is_superuser(self, superuser: bool):
"""Ensure that the user creating this group has permissions to set the superuser flag"""
@@ -153,8 +159,8 @@ class GroupSerializer(ModelSerializer):
"num_pk",
"name",
"is_superuser",
"parent",
"parent_name",
"parents",
"parents_obj",
"users",
"users_obj",
"attributes",
@@ -171,9 +177,10 @@ class GroupSerializer(ModelSerializer):
"required": False,
"default": list,
},
# TODO: This field isn't unique on the database which is hard to backport
# hence we just validate the uniqueness here
"name": {"validators": [UniqueValidator(Group.objects.all())]},
"parents": {
"required": False,
"default": list,
},
}
@@ -252,7 +259,7 @@ class GroupViewSet(UsedByMixin, ModelViewSet):
]
def get_queryset(self):
base_qs = Group.objects.all().select_related("parent").prefetch_related("roles")
base_qs = Group.objects.all().prefetch_related("roles")
if self.serializer_class(context={"request": self.request})._should_include_users:
base_qs = base_qs.prefetch_related("users")
@@ -264,12 +271,16 @@ class GroupViewSet(UsedByMixin, ModelViewSet):
if self.serializer_class(context={"request": self.request})._should_include_children:
base_qs = base_qs.prefetch_related("children")
if self.serializer_class(context={"request": self.request})._should_include_parents:
base_qs = base_qs.prefetch_related("parents")
return base_qs
@extend_schema(
parameters=[
OpenApiParameter("include_users", bool, default=True),
OpenApiParameter("include_children", bool, default=False),
OpenApiParameter("include_parents", bool, default=False),
]
)
def list(self, request, *args, **kwargs):
@@ -279,6 +290,7 @@ class GroupViewSet(UsedByMixin, ModelViewSet):
parameters=[
OpenApiParameter("include_users", bool, default=True),
OpenApiParameter("include_children", bool, default=False),
OpenApiParameter("include_parents", bool, default=False),
]
)
def retrieve(self, request, *args, **kwargs):

View File

@@ -4,7 +4,7 @@ from typing import Any
from django.utils.timezone import now
from drf_spectacular.utils import OpenApiResponse, extend_schema
from guardian.shortcuts import assign_perm, get_anonymous_user
from guardian.shortcuts import get_anonymous_user
from rest_framework.decorators import action
from rest_framework.exceptions import ValidationError
from rest_framework.fields import CharField
@@ -157,7 +157,9 @@ class TokenViewSet(UsedByMixin, ModelViewSet):
user=self.request.user,
expiring=self.request.user.attributes.get(USER_ATTRIBUTE_TOKEN_EXPIRING, True),
)
assign_perm("authentik_core.view_token_key", self.request.user, instance)
self.request.user.assign_perms_to_managed_role(
"authentik_core.view_token_key", instance
)
return instance
return super().perform_create(serializer)

View File

@@ -81,7 +81,7 @@ class UsedByMixin:
# query and check if there is a difference between modes the user can see
# and can't see and add a warning
for obj in get_objects_for_user(
request.user, f"{app}.view_{model_name}", manager
request.user, f"{app}.view_{model_name}", manager.all()
).all():
# Only merge shadows on first object
if first_object:

View File

@@ -86,8 +86,9 @@ from authentik.flows.models import FlowToken
from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlanner
from authentik.flows.views.executor import QS_KEY_TOKEN
from authentik.lib.avatars import get_avatar
from authentik.rbac.api.roles import RoleSerializer
from authentik.rbac.decorators import permission_required
from authentik.rbac.models import get_permission_choices
from authentik.rbac.models import Role, get_permission_choices
from authentik.stages.email.flow import pickle_flow_token_for_email
from authentik.stages.email.models import EmailStage
from authentik.stages.email.tasks import send_mails
@@ -106,7 +107,6 @@ class PartialGroupSerializer(ModelSerializer):
"""Partial Group Serializer, does not include child relations."""
attributes = JSONDictField(required=False)
parent_name = CharField(source="parent.name", read_only=True, allow_null=True)
class Meta:
model = Group
@@ -115,8 +115,6 @@ class PartialGroupSerializer(ModelSerializer):
"num_pk",
"name",
"is_superuser",
"parent",
"parent_name",
"attributes",
]
@@ -135,6 +133,13 @@ class UserSerializer(ModelSerializer):
default=list,
)
groups_obj = SerializerMethodField(allow_null=True)
roles = PrimaryKeyRelatedField(
allow_empty=True,
many=True,
queryset=Role.objects.all().order_by("name"),
default=list,
)
roles_obj = SerializerMethodField(allow_null=True)
uid = CharField(read_only=True)
username = CharField(
max_length=150,
@@ -148,12 +153,25 @@ class UserSerializer(ModelSerializer):
return True
return str(request.query_params.get("include_groups", "true")).lower() == "true"
@property
def _should_include_roles(self) -> bool:
request: Request = self.context.get("request", None)
if not request:
return True
return str(request.query_params.get("include_roles", "true")).lower() == "true"
@extend_schema_field(PartialGroupSerializer(many=True))
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
@extend_schema_field(RoleSerializer(many=True))
def get_roles_obj(self, instance: User) -> list[RoleSerializer] | None:
if not self._should_include_roles:
return None
return RoleSerializer(instance.roles, many=True).data
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if SERIALIZER_CONTEXT_BLUEPRINT in self.context:
@@ -168,24 +186,26 @@ class UserSerializer(ModelSerializer):
directly setting a password. However should be done via the `set_password`
method instead of directly setting it like rest_framework."""
password = validated_data.pop("password", None)
permissions = Permission.objects.filter(
perms_qs = Permission.objects.filter(
codename__in=[x.split(".")[1] for x in validated_data.pop("permissions", [])]
)
validated_data["user_permissions"] = permissions
).values_list("content_type__app_label", "codename")
perms_list = [f"{ct}.{name}" for ct, name in list(perms_qs)]
instance: User = super().create(validated_data)
self._set_password(instance, password)
instance.assign_perms_to_managed_role(perms_list)
return instance
def update(self, instance: User, validated_data: dict) -> User:
"""Same as `create` above, set the password directly if we're in a blueprint
context"""
password = validated_data.pop("password", None)
permissions = Permission.objects.filter(
perms_qs = Permission.objects.filter(
codename__in=[x.split(".")[1] for x in validated_data.pop("permissions", [])]
)
validated_data["user_permissions"] = permissions
).values_list("content_type__app_label", "codename")
perms_list = [f"{ct}.{name}" for ct, name in list(perms_qs)]
instance = super().update(instance, validated_data)
self._set_password(instance, password)
instance.assign_perms_to_managed_role(perms_list)
return instance
def _set_password(self, instance: User, password: str | None):
@@ -240,6 +260,8 @@ class UserSerializer(ModelSerializer):
"is_superuser",
"groups",
"groups_obj",
"roles",
"roles_obj",
"email",
"avatar",
"attributes",
@@ -263,6 +285,7 @@ class UserSelfSerializer(ModelSerializer):
is_superuser = BooleanField(read_only=True)
avatar = SerializerMethodField()
groups = SerializerMethodField()
roles = SerializerMethodField()
uid = CharField(read_only=True)
settings = SerializerMethodField()
system_permissions = SerializerMethodField()
@@ -290,6 +313,25 @@ class UserSelfSerializer(ModelSerializer):
"pk": group.pk,
}
@extend_schema_field(
ListSerializer(
child=inline_serializer(
"UserSelfRoles",
{
"name": CharField(read_only=True),
"pk": CharField(read_only=True),
},
)
)
)
def get_roles(self, _: User):
"""Return only the roles a user is member of"""
for role in self.instance.all_roles().order_by("name"):
yield {
"name": role.name,
"pk": role.pk,
}
def get_settings(self, user: User) -> dict[str, Any]:
"""Get user settings with brand and group settings applied"""
return user.group_attributes(self._context["request"]).get("settings", {})
@@ -311,6 +353,7 @@ class UserSelfSerializer(ModelSerializer):
"is_active",
"is_superuser",
"groups",
"roles",
"email",
"avatar",
"uid",
@@ -390,6 +433,16 @@ class UsersFilter(FilterSet):
queryset=Group.objects.all().order_by("name"),
)
roles_by_name = ModelMultipleChoiceFilter(
field_name="roles__name",
to_field_name="name",
queryset=Role.objects.all().order_by("name"),
)
roles_by_pk = ModelMultipleChoiceFilter(
field_name="roles",
queryset=Role.objects.all().order_by("name"),
)
def filter_is_superuser(self, queryset, name, value):
if value:
return queryset.filter(ak_groups__is_superuser=True).distinct()
@@ -425,6 +478,8 @@ class UsersFilter(FilterSet):
"attributes",
"groups_by_name",
"groups_by_pk",
"roles_by_name",
"roles_by_pk",
"type",
]
@@ -465,11 +520,14 @@ class UserViewSet(UsedByMixin, ModelViewSet):
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")
if self.serializer_class(context={"request": self.request})._should_include_roles:
base_qs = base_qs.prefetch_related("roles")
return base_qs
@extend_schema(
parameters=[
OpenApiParameter("include_groups", bool, default=True),
OpenApiParameter("include_roles", bool, default=True),
]
)
def list(self, request, *args, **kwargs):

View File

@@ -12,7 +12,27 @@ from authentik.flows.views.executor import SESSION_KEY_PLAN
from authentik.stages.password.stage import PLAN_CONTEXT_METHOD, PLAN_CONTEXT_METHOD_ARGS
class InbuiltBackend(ModelBackend):
class ModelBackendNoAuthz(ModelBackend):
def get_user_permissions(self, user_obj, obj=None):
return set()
def get_group_permissions(self, user_obj, obj=None):
return set()
def get_all_permissions(self, user_obj, obj=None):
return set()
def has_perm(self, user_obj, perm, obj=None):
return False
def has_module_perms(self, user_obj, app_label):
return False
def with_perm(self, perm, is_active=True, include_superusers=True, obj=None):
return User.objects.none()
class InbuiltBackend(ModelBackendNoAuthz):
"""Inbuilt backend"""
def authenticate(

View File

@@ -6,7 +6,6 @@ import django.contrib.auth.models
import django.contrib.auth.validators
import django.db.models.deletion
import django.utils.timezone
import guardian.mixins
from django.conf import settings
from django.db import migrations, models
@@ -111,7 +110,7 @@ class Migration(migrations.Migration):
options={
"permissions": (("reset_user_password", "Reset Password"),),
},
bases=(guardian.mixins.GuardianUserMixin, models.Model),
bases=(models.Model,),
managers=[
("objects", django.contrib.auth.models.UserManager()),
],

View File

@@ -0,0 +1,155 @@
# Generated by Django 5.1.12 on 2025-09-12 08:38
import django.db.models.deletion
import pgtrigger.compiler
import pgtrigger.migrations
import psqlextra.backend.migrations.operations.apply_state
import psqlextra.backend.migrations.operations.create_materialized_view_model
import psqlextra.indexes.unique_index
import psqlextra.manager.manager
import psqlextra.models.view
import uuid
from django.apps.registry import Apps
from django.db import migrations, models
from django.db.backends.base.schema import BaseDatabaseSchemaEditor
def migrate_parents(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
Group = apps.get_model("authentik_core", "Group")
db_alias = schema_editor.connection.alias
for group in Group.objects.using(db_alias).all():
if not group.parent:
continue
group.parents.add(group.parent)
group.save()
class Migration(migrations.Migration):
dependencies = [
("authentik_core", "0054_alter_application_meta_icon_alter_source_icon"),
]
operations = [
migrations.CreateModel(
name="GroupParentageNode",
fields=[
(
"uuid",
models.UUIDField(
default=uuid.uuid4, editable=False, primary_key=True, serialize=False
),
),
],
options={
"verbose_name": "Group Parentage Node",
"verbose_name_plural": "Group Parentage Nodes",
"db_table": "authentik_core_groupparentage",
},
),
migrations.AddField(
model_name="groupparentagenode",
name="child",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="parent_nodes",
to="authentik_core.group",
),
),
migrations.AddField(
model_name="groupparentagenode",
name="parent",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="child_nodes",
to="authentik_core.group",
),
),
psqlextra.backend.migrations.operations.create_materialized_view_model.PostgresCreateMaterializedViewModel(
name="GroupAncestryNode",
fields=[
(
"id",
models.AutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
),
),
],
options={
"db_table": "authentik_core_groupancestry",
},
view_options={
"query": (
"\n WITH RECURSIVE accumulator AS (\n SELECT\n child_id::text || '-' || parent_id::text as id,\n child_id AS descendant_id,\n parent_id AS ancestor_id\n FROM authentik_core_groupparentage\n\n UNION\n\n SELECT\n accumulator.descendant_id::text || '-' || current.parent_id::text as id,\n accumulator.descendant_id,\n current.parent_id AS ancestor_id\n FROM accumulator\n JOIN authentik_core_groupparentage current\n ON accumulator.ancestor_id = current.child_id\n )\n SELECT * FROM accumulator\n ",
(),
),
},
bases=(psqlextra.models.view.PostgresMaterializedViewModel,),
managers=[
("objects", psqlextra.manager.manager.PostgresManager()),
],
),
psqlextra.backend.migrations.operations.apply_state.ApplyState(
state_operation=migrations.AddField(
model_name="groupancestrynode",
name="ancestor",
field=models.ForeignKey(
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="descendant_nodes",
to="authentik_core.group",
),
),
),
psqlextra.backend.migrations.operations.apply_state.ApplyState(
state_operation=migrations.AddField(
model_name="groupancestrynode",
name="descendant",
field=models.ForeignKey(
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="ancestor_nodes",
to="authentik_core.group",
),
),
),
migrations.AddIndex(
model_name="groupancestrynode",
index=models.Index(fields=["descendant"], name="authentik_c_descend_f83a71_idx"),
),
migrations.AddIndex(
model_name="groupancestrynode",
index=models.Index(fields=["ancestor"], name="authentik_c_ancesto_974845_idx"),
),
migrations.AddIndex(
model_name="groupancestrynode",
index=psqlextra.indexes.unique_index.UniqueIndex(
fields=["id"], name="authentik_c_id_5d0bb4_idx"
),
),
pgtrigger.migrations.AddTrigger(
model_name="groupparentagenode",
trigger=pgtrigger.compiler.Trigger(
name="refresh_groupancestry",
sql=pgtrigger.compiler.UpsertTriggerSql(
func="\n REFRESH MATERIALIZED VIEW CONCURRENTLY authentik_core_groupancestry;\n RETURN NULL;\n ",
hash="a987621714359aa0389e03fd2d52f86b118e7d24",
operation="INSERT OR UPDATE OR DELETE",
pgid="pgtrigger_refresh_groupancestry_62450",
table="authentik_core_groupparentage",
when="AFTER",
),
),
),
migrations.AddField(
model_name="group",
name="parents",
field=models.ManyToManyField(
blank=True,
related_name="children",
through="authentik_core.GroupParentageNode",
to="authentik_core.group",
),
),
migrations.RunPython(migrate_parents, migrations.RunPython.noop),
]

View File

@@ -0,0 +1,180 @@
# Generated by Django 5.1.12 on 2025-09-30 12:29
from django.db import migrations, models
from django.apps.registry import Apps
from django.db.backends.base.schema import BaseDatabaseSchemaEditor
def migrate_object_permissions(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
db_alias = schema_editor.connection.alias
User = apps.get_model("authentik_core", "User")
Group = apps.get_model("auth", "Group")
Role = apps.get_model("authentik_rbac", "Role")
UserObjectPermission = apps.get_model("guardian", "UserObjectPermission")
GroupObjectPermission = apps.get_model("guardian", "GroupObjectPermission")
RoleObjectPermission = apps.get_model("guardian", "RoleObjectPermission")
RoleModelPermission = apps.get_model("guardian", "RoleModelPermission")
def get_role_for_user_id(user_id: int) -> Role:
name = f"ak-managed-role--user-{user_id}"
role, created = Role.objects.using(db_alias).get_or_create(
name=name,
managed=name,
)
if created:
role.users.add(user_id)
return role
def get_role_for_group_id(group_id: int) -> Role:
role = Role.objects.using(db_alias).filter(group_id=group_id).first()
if not role:
# Every django group should already have a role, so this should never happen.
# But let's be nice.
name = f"ak-managed-role--group-{group_id}"
role, created = Role.objects.using(db_alias).get_or_create(
group_id=group_id,
name=name,
managed=name,
)
if created:
role.group_id = group_id
role.save()
return role
# Below are 4 very similar pieces of code, for (user, group) x (model, object).
# Since this is a one-off migration, I won't attempt DRYing them.
# User model permissions
user_ids_with_model_permissions = (
User.user_permissions.through.objects.using(db_alias)
.values_list("user", flat=True)
.distinct()
)
for user_id in user_ids_with_model_permissions:
role = get_role_for_user_id(user_id)
user_model_permissions = User.user_permissions.through.objects.using(db_alias).filter(
user_id=user_id
)
role_model_permissions = []
for user_model_permission in user_model_permissions:
role_model_permissions.append(
RoleModelPermission(
permission=user_model_permission.permission,
content_type=user_model_permission.permission.content_type,
role=role,
)
)
RoleModelPermission.objects.using(db_alias).bulk_create(role_model_permissions)
# Group model permissions
group_ids_with_model_permissions = (
Group.permissions.through.objects.using(db_alias).values_list("group", flat=True).distinct()
)
for group_id in group_ids_with_model_permissions:
role = get_role_for_group_id(group_id)
group_model_permissions = Group.permissions.through.objects.using(db_alias).filter(
group_id=group_id
)
role_model_permissions = []
for group_model_permission in group_model_permissions:
role_model_permissions.append(
RoleModelPermission(
permission=group_model_permission.permission,
content_type=group_model_permission.permission.content_type,
role=role,
)
)
RoleModelPermission.objects.using(db_alias).bulk_create(role_model_permissions)
# User object permissions
user_ids_with_object_permissions = (
UserObjectPermission.objects.using(db_alias).values_list("user", flat=True).distinct()
)
for user_id in user_ids_with_object_permissions:
role = get_role_for_user_id(user_id)
user_object_permissions = UserObjectPermission.objects.using(db_alias).filter(user=user_id)
role_object_permissions = []
for user_object_permission in user_object_permissions:
role_object_permissions.append(
RoleObjectPermission(
permission=user_object_permission.permission,
content_type=user_object_permission.content_type,
object_pk=user_object_permission.object_pk,
role=role,
)
)
RoleObjectPermission.objects.using(db_alias).bulk_create(role_object_permissions)
# Group object permissions
group_ids_with_object_permissions = (
GroupObjectPermission.objects.using(db_alias).values_list("group", flat=True).distinct()
)
for group_id in group_ids_with_object_permissions:
role = get_role_for_group_id(group_id)
group_object_permissions = GroupObjectPermission.objects.using(db_alias).filter(
group=group_id
)
role_object_permissions = []
for group_object_permission in group_object_permissions:
role_object_permissions.append(
RoleObjectPermission(
permission=group_object_permission.permission,
content_type=group_object_permission.content_type,
object_pk=group_object_permission.object_pk,
role=role,
)
)
RoleObjectPermission.objects.using(db_alias).bulk_create(role_object_permissions)
class Migration(migrations.Migration):
dependencies = [
("guardian", "0004_role_permissions"),
("authentik_core", "0055_groupancestor_groupparentagenode_group_parents"),
("authentik_rbac", "0008_alter_role_group"),
]
operations = [
migrations.AddField(
model_name="user",
name="roles",
field=models.ManyToManyField(
blank=True, related_name="users", to="authentik_rbac.role"
),
),
migrations.RunPython(migrate_object_permissions),
migrations.AlterUniqueTogether(
name="group",
unique_together=set(),
),
migrations.AlterField(
model_name="group",
name="parents",
field=models.ManyToManyField(
blank=True,
related_name="children",
through="authentik_core.GroupParentageNode",
to="authentik_core.group",
),
),
migrations.RemoveField(
model_name="group",
name="parent",
),
migrations.AlterField(
model_name="group",
name="name",
field=models.TextField(unique=True, verbose_name="name"),
),
]

View File

@@ -6,9 +6,10 @@ from hashlib import sha256
from typing import Any, Optional, Self
from uuid import uuid4
import pgtrigger
from deepmerge import always_merger
from django.contrib.auth.hashers import check_password
from django.contrib.auth.models import AbstractUser
from django.contrib.auth.models import AbstractUser, Permission
from django.contrib.auth.models import UserManager as DjangoUserManager
from django.contrib.sessions.base_session import AbstractBaseSession
from django.core.validators import validate_slug
@@ -19,10 +20,11 @@ from django.http import HttpRequest
from django.utils.functional import cached_property
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _
from django_cte import CTE, with_cte
from guardian.conf import settings
from guardian.mixins import GuardianUserMixin
from guardian.models import RoleModelPermission, RoleObjectPermission
from model_utils.managers import InheritanceManager
from psqlextra.indexes import UniqueIndex
from psqlextra.models import PostgresMaterializedViewModel
from rest_framework.serializers import Serializer
from structlog.stdlib import get_logger
@@ -43,6 +45,7 @@ from authentik.lib.models import (
)
from authentik.lib.utils.time import timedelta_from_string
from authentik.policies.models import PolicyBindingModel
from authentik.rbac.models import Role
from authentik.tenants.models import DEFAULT_TOKEN_DURATION, DEFAULT_TOKEN_LENGTH
from authentik.tenants.utils import get_current_tenant, get_unique_identifier
@@ -69,6 +72,17 @@ options.DEFAULT_NAMES = options.DEFAULT_NAMES + (
GROUP_RECURSION_LIMIT = 20
MANAGED_ROLE_PREFIX_USER = "ak-managed-role--user"
MANAGED_ROLE_PREFIX_GROUP = "ak-managed-role--group"
def managed_role_name(user_or_group: models.Model):
if isinstance(user_or_group, User):
return f"{MANAGED_ROLE_PREFIX_USER}-{user_or_group.pk}"
if isinstance(user_or_group, Group):
return f"{MANAGED_ROLE_PREFIX_GROUP}-{user_or_group.pk}"
raise TypeError("Managed roles are only available for User or Group.")
def default_token_duration() -> datetime:
"""Default duration a Token is valid"""
@@ -148,69 +162,40 @@ class AttributesMixin(models.Model):
class GroupQuerySet(QuerySet):
def with_children_recursive(self):
"""Recursively get all groups that have the current queryset as parents
or are indirectly related."""
def with_descendants(self):
pks = self.values_list("pk", flat=True)
return Group.objects.filter(Q(pk__in=pks) | Q(ancestor_nodes__ancestor__in=pks)).distinct()
def make_cte(cte):
"""Build the query that ends up in WITH RECURSIVE"""
# Start from self, aka the current query
# Add a depth attribute to limit the recursion
return self.annotate(
relative_depth=models.Value(0, output_field=models.IntegerField())
).union(
# Here is the recursive part of the query. cte refers to the previous iteration
# Only select groups for which the parent is part of the previous iteration
# and increase the depth
# Finally, limit the depth
cte.join(Group, group_uuid=cte.col.parent_id)
.annotate(
relative_depth=models.ExpressionWrapper(
cte.col.relative_depth
+ models.Value(1, output_field=models.IntegerField()),
output_field=models.IntegerField(),
)
)
.filter(relative_depth__lt=GROUP_RECURSION_LIMIT),
all=True,
)
# Build the recursive query, see above
cte = CTE.recursive(make_cte)
# Return the result, as a usable queryset for Group.
return with_cte(cte, select=cte.join(Group, group_uuid=cte.col.group_uuid))
def with_ancestors(self):
pks = self.values_list("pk", flat=True)
return Group.objects.filter(
Q(pk__in=pks) | Q(descendant_nodes__descendant__in=pks)
).distinct()
class Group(SerializerModel, AttributesMixin):
"""Group model which supports a basic hierarchy and has attributes"""
"""Group model which supports a hierarchy and has attributes"""
group_uuid = models.UUIDField(primary_key=True, editable=False, default=uuid4)
name = models.TextField(_("name"))
name = models.TextField(verbose_name=_("name"), unique=True)
is_superuser = models.BooleanField(
default=False, help_text=_("Users added to this group will be superusers.")
)
roles = models.ManyToManyField("authentik_rbac.Role", related_name="ak_groups", blank=True)
parent = models.ForeignKey(
parents = models.ManyToManyField(
"Group",
blank=True,
null=True,
default=None,
on_delete=models.SET_NULL,
symmetrical=False,
through="GroupParentageNode",
related_name="children",
)
objects = GroupQuerySet.as_manager()
class Meta:
unique_together = (
(
"name",
"parent",
),
)
indexes = (
models.Index(fields=["name"]),
models.Index(fields=["is_superuser"]),
@@ -244,12 +229,103 @@ class Group(SerializerModel, AttributesMixin):
"""Recursively check if `user` is member of us, or any parent."""
return user.all_groups().filter(group_uuid=self.group_uuid).exists()
def children_recursive(self: Self | QuerySet["Group"]) -> QuerySet["Group"]:
"""Compatibility layer for Group.objects.with_children_recursive()"""
qs = self
if not isinstance(self, QuerySet):
qs = Group.objects.filter(group_uuid=self.group_uuid)
return qs.with_children_recursive()
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()
).distinct()
def get_managed_role(self, create=False):
if create:
name = managed_role_name(self)
role, created = Role.objects.get_or_create(name=name, managed=name)
if created:
role.ak_groups.add(self)
return role
else:
return Role.objects.filter(name=managed_role_name(self)).first()
def assign_perms_to_managed_role(
self,
perms: str | list[str] | Permission | list[Permission],
obj: models.Model | None = None,
):
if not perms:
return
role = self.get_managed_role(create=True)
role.assign_perms(perms, obj)
class GroupParentageNode(models.Model):
uuid = models.UUIDField(primary_key=True, editable=False, default=uuid4)
child = models.ForeignKey(Group, related_name="parent_nodes", on_delete=models.CASCADE)
parent = models.ForeignKey(Group, related_name="child_nodes", on_delete=models.CASCADE)
class Meta:
verbose_name = _("Group Parentage Node")
verbose_name_plural = _("Group Parentage Nodes")
db_table = "authentik_core_groupparentage"
triggers = [
pgtrigger.Trigger(
name="refresh_groupancestry",
operation=pgtrigger.Insert | pgtrigger.Update | pgtrigger.Delete,
when=pgtrigger.After,
func="""
REFRESH MATERIALIZED VIEW CONCURRENTLY authentik_core_groupancestry;
RETURN NULL;
""",
),
]
def __str__(self) -> str:
return f"Group Parentage Node from #{self.child_id} to {self.parent_id}"
class GroupAncestryNode(PostgresMaterializedViewModel):
descendant = models.ForeignKey(
Group, related_name="ancestor_nodes", on_delete=models.DO_NOTHING
)
ancestor = models.ForeignKey(
Group, related_name="descendant_nodes", on_delete=models.DO_NOTHING
)
class Meta:
# This is a transitive closure of authentik_core_groupparentage
# See https://en.wikipedia.org/wiki/Transitive_closure#In_graph_theory
db_table = "authentik_core_groupancestry"
indexes = [
models.Index(fields=["descendant"]),
models.Index(fields=["ancestor"]),
UniqueIndex(fields=["id"]),
]
class ViewMeta:
query = """
WITH RECURSIVE accumulator AS (
SELECT
child_id::text || '-' || parent_id::text as id,
child_id AS descendant_id,
parent_id AS ancestor_id
FROM authentik_core_groupparentage
UNION
SELECT
accumulator.descendant_id::text || '-' || current.parent_id::text as id,
accumulator.descendant_id,
current.parent_id AS ancestor_id
FROM accumulator
JOIN authentik_core_groupparentage current
ON accumulator.ancestor_id = current.child_id
)
SELECT * FROM accumulator
"""
def __str__(self) -> str:
return f"Group Ancestry Node from {self.descendant_id} to {self.ancestor_id}"
class UserQuerySet(models.QuerySet):
@@ -276,7 +352,7 @@ class UserManager(DjangoUserManager):
return self.get_queryset().exclude_anonymous()
class User(SerializerModel, GuardianUserMixin, AttributesMixin, AbstractUser):
class User(SerializerModel, AttributesMixin, AbstractUser):
"""authentik User model, based on django's contrib auth user model."""
uuid = models.UUIDField(default=uuid4, editable=False, unique=True)
@@ -286,6 +362,7 @@ class User(SerializerModel, GuardianUserMixin, AttributesMixin, AbstractUser):
sources = models.ManyToManyField("Source", through="UserSourceConnection")
ak_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)
last_updated = models.DateTimeField(auto_now=True)
@@ -323,7 +400,60 @@ class User(SerializerModel, GuardianUserMixin, AttributesMixin, AbstractUser):
def all_groups(self) -> QuerySet[Group]:
"""Recursively get all groups this user is a member of."""
return self.ak_groups.all().with_children_recursive()
return self.ak_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()
def get_managed_role(self, create=False):
if create:
name = managed_role_name(self)
role, created = Role.objects.get_or_create(name=name, managed=name)
if created:
role.users.add(self)
return role
else:
return Role.objects.filter(name=managed_role_name(self)).first()
def get_all_model_perms_on_managed_role(self) -> QuerySet[RoleModelPermission]:
role = self.get_managed_role()
if not role:
return RoleModelPermission.objects.none()
return RoleModelPermission.objects.filter(role=role)
def get_all_obj_perms_on_managed_role(self) -> QuerySet[RoleObjectPermission]:
role = self.get_managed_role()
if not role:
return RoleObjectPermission.objects.none()
return RoleObjectPermission.objects.filter(role=role)
def assign_perms_to_managed_role(
self,
perms: str | list[str] | Permission | list[Permission],
obj: models.Model | None = None,
):
if not perms:
return
role = self.get_managed_role(create=True)
role.assign_perms(perms, obj)
def remove_perms_from_managed_role(
self,
perms: str | list[str] | Permission | list[Permission],
obj: models.Model | None = None,
):
role = self.get_managed_role()
if not role:
return None
role.remove_perms(perms, obj)
def remove_all_perms_from_managed_role(self):
role = self.get_managed_role()
if not role:
return None
RoleModelPermission.objects.filter(role=role).delete()
RoleObjectPermission.objects.filter(role=role).delete()
def group_attributes(self, request: HttpRequest | None = None) -> dict[str, Any]:
"""Get a dictionary containing the attributes from all groups the user belongs to,

View File

@@ -41,7 +41,6 @@ class SessionStore(SessionBase):
)
.prefetch_related(
"authenticatedsession__user__groups",
"authenticatedsession__user__user_permissions",
)
.get(
session_key=self.session_key,
@@ -62,7 +61,6 @@ class SessionStore(SessionBase):
)
.prefetch_related(
"authenticatedsession__user__groups",
"authenticatedsession__user__user_permissions",
)
.aget(
session_key=self.session_key,

View File

@@ -1,7 +1,6 @@
"""Test Application Entitlements API"""
from django.urls import reverse
from guardian.shortcuts import assign_perm
from rest_framework.test import APITestCase
from authentik.core.models import Application, ApplicationEntitlement, Group
@@ -49,7 +48,8 @@ class TestApplicationEntitlements(APITestCase):
def test_group_indirect(self):
"""Test indirect group"""
parent = Group.objects.create(name=generate_id())
group = Group.objects.create(name=generate_id(), parent=parent)
group = Group.objects.create(name=generate_id())
group.parents.add(parent)
self.user.ak_groups.add(group)
ent = ApplicationEntitlement.objects.create(app=self.app, name=generate_id())
PolicyBinding.objects.create(target=ent, group=parent, order=0)
@@ -76,8 +76,8 @@ class TestApplicationEntitlements(APITestCase):
def test_api_perms_global(self):
"""Test API creation with global permissions"""
assign_perm("authentik_core.add_applicationentitlement", self.user)
assign_perm("authentik_core.view_application", self.user)
self.user.assign_perms_to_managed_role("authentik_core.add_applicationentitlement")
self.user.assign_perms_to_managed_role("authentik_core.view_application")
self.client.force_login(self.user)
res = self.client.post(
reverse("authentik_api:applicationentitlement-list"),
@@ -90,8 +90,8 @@ class TestApplicationEntitlements(APITestCase):
def test_api_perms_scoped(self):
"""Test API creation with scoped permissions"""
assign_perm("authentik_core.add_applicationentitlement", self.user)
assign_perm("authentik_core.view_application", self.user, self.app)
self.user.assign_perms_to_managed_role("authentik_core.add_applicationentitlement")
self.user.assign_perms_to_managed_role("authentik_core.view_application", self.app)
self.client.force_login(self.user)
res = self.client.post(
reverse("authentik_api:applicationentitlement-list"),
@@ -104,7 +104,7 @@ class TestApplicationEntitlements(APITestCase):
def test_api_perms_missing(self):
"""Test API creation with no permissions"""
assign_perm("authentik_core.add_applicationentitlement", self.user)
self.user.assign_perms_to_managed_role("authentik_core.add_applicationentitlement")
self.client.force_login(self.user)
res = self.client.post(
reverse("authentik_api:applicationentitlement-list"),

View File

@@ -25,7 +25,8 @@ class TestGroups(TestCase):
user = User.objects.create(username=generate_id())
user2 = User.objects.create(username=generate_id())
parent = Group.objects.create(name=generate_id())
child = Group.objects.create(name=generate_id(), parent=parent)
child = Group.objects.create(name=generate_id())
child.parents.add(parent)
child.users.add(user)
self.assertTrue(child.is_member(user))
self.assertTrue(parent.is_member(user))
@@ -37,8 +38,10 @@ class TestGroups(TestCase):
user = User.objects.create(username=generate_id())
user2 = User.objects.create(username=generate_id())
parent = Group.objects.create(name=generate_id())
second = Group.objects.create(name=generate_id(), parent=parent)
third = Group.objects.create(name=generate_id(), parent=second)
second = Group.objects.create(name=generate_id())
second.parents.add(parent)
third = Group.objects.create(name=generate_id())
third.parents.add(second)
second.users.add(user)
self.assertTrue(parent.is_member(user))
self.assertFalse(parent.is_member(user2))
@@ -51,9 +54,21 @@ class TestGroups(TestCase):
"""Test group membership (recursive)"""
user = User.objects.create(username=generate_id())
group = Group.objects.create(name=generate_id())
group2 = Group.objects.create(name=generate_id(), parent=group)
group2 = Group.objects.create(name=generate_id())
group.parents.add(group2)
group2.parents.add(group)
group.users.add(user)
group.parent = group2
group.save()
self.assertTrue(group.is_member(user))
self.assertTrue(group2.is_member(user))
def test_group_managed_role(self):
"""Test group managed role"""
perm = "authentik_core.view_user"
user = User.objects.create(username=generate_id())
group = Group.objects.create(name=generate_id())
group.users.add(user)
group.assign_perms_to_managed_role(perm)
self.assertEqual(group.roles.count(), 1)
self.assertEqual(user.roles.count(), 0)
self.assertTrue(user.has_perm(perm))

View File

@@ -1,7 +1,6 @@
"""Test Groups API"""
from django.urls.base import reverse
from guardian.shortcuts import assign_perm
from rest_framework.test import APITestCase
from authentik.core.models import Group
@@ -37,8 +36,8 @@ class TestGroupsAPI(APITestCase):
def test_add_user(self):
"""Test add_user"""
group = Group.objects.create(name=generate_id())
assign_perm("authentik_core.add_user_to_group", self.login_user, group)
assign_perm("authentik_core.view_user", self.login_user)
self.login_user.assign_perms_to_managed_role("authentik_core.add_user_to_group", group)
self.login_user.assign_perms_to_managed_role("authentik_core.view_user")
self.client.force_login(self.login_user)
res = self.client.post(
reverse("authentik_api:group-add-user", kwargs={"pk": group.pk}),
@@ -53,8 +52,8 @@ class TestGroupsAPI(APITestCase):
def test_add_user_404(self):
"""Test add_user"""
group = Group.objects.create(name=generate_id())
assign_perm("authentik_core.add_user_to_group", self.login_user, group)
assign_perm("authentik_core.view_user", self.login_user)
self.login_user.assign_perms_to_managed_role("authentik_core.add_user_to_group", group)
self.login_user.assign_perms_to_managed_role("authentik_core.view_user")
self.client.force_login(self.login_user)
res = self.client.post(
reverse("authentik_api:group-add-user", kwargs={"pk": group.pk}),
@@ -67,8 +66,8 @@ class TestGroupsAPI(APITestCase):
def test_remove_user(self):
"""Test remove_user"""
group = Group.objects.create(name=generate_id())
assign_perm("authentik_core.remove_user_from_group", self.login_user, group)
assign_perm("authentik_core.view_user", self.login_user)
self.login_user.assign_perms_to_managed_role("authentik_core.remove_user_from_group", group)
self.login_user.assign_perms_to_managed_role("authentik_core.view_user")
group.users.add(self.user)
self.client.force_login(self.login_user)
res = self.client.post(
@@ -84,8 +83,8 @@ class TestGroupsAPI(APITestCase):
def test_remove_user_404(self):
"""Test remove_user"""
group = Group.objects.create(name=generate_id())
assign_perm("authentik_core.remove_user_from_group", self.login_user, group)
assign_perm("authentik_core.view_user", self.login_user)
self.login_user.assign_perms_to_managed_role("authentik_core.remove_user_from_group", group)
self.login_user.assign_perms_to_managed_role("authentik_core.view_user")
group.users.add(self.user)
self.client.force_login(self.login_user)
res = self.client.post(
@@ -96,23 +95,9 @@ class TestGroupsAPI(APITestCase):
)
self.assertEqual(res.status_code, 404)
def test_parent_self(self):
"""Test parent"""
group = Group.objects.create(name=generate_id())
assign_perm("view_group", self.login_user, group)
assign_perm("change_group", self.login_user, group)
self.client.force_login(self.login_user)
res = self.client.patch(
reverse("authentik_api:group-detail", kwargs={"pk": group.pk}),
data={
"parent": group.pk,
},
)
self.assertEqual(res.status_code, 400)
def test_superuser_no_perm(self):
"""Test creating a superuser group without permission"""
assign_perm("authentik_core.add_group", self.login_user)
self.login_user.assign_perms_to_managed_role("authentik_core.add_group")
self.client.force_login(self.login_user)
res = self.client.post(
reverse("authentik_api:group-list"),
@@ -126,7 +111,7 @@ class TestGroupsAPI(APITestCase):
def test_superuser_no_perm_no_superuser(self):
"""Test creating a group without permission and without superuser flag"""
assign_perm("authentik_core.add_group", self.login_user)
self.login_user.assign_perms_to_managed_role("authentik_core.add_group")
self.client.force_login(self.login_user)
res = self.client.post(
reverse("authentik_api:group-list"),
@@ -137,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)
assign_perm("view_group", self.login_user, group)
assign_perm("change_group", self.login_user, group)
self.login_user.assign_perms_to_managed_role("view_group", group)
self.login_user.assign_perms_to_managed_role("change_group", group)
self.client.force_login(self.login_user)
res = self.client.patch(
reverse("authentik_api:group-detail", kwargs={"pk": group.pk}),
@@ -154,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)
assign_perm("view_group", self.login_user, group)
assign_perm("change_group", self.login_user, group)
self.login_user.assign_perms_to_managed_role("view_group", group)
self.login_user.assign_perms_to_managed_role("change_group", group)
self.client.force_login(self.login_user)
res = self.client.patch(
reverse("authentik_api:group-detail", kwargs={"pk": group.pk}),
@@ -165,8 +150,8 @@ class TestGroupsAPI(APITestCase):
def test_superuser_create(self):
"""Test creating a superuser group with permission"""
assign_perm("authentik_core.add_group", self.login_user)
assign_perm("authentik_core.enable_group_superuser", self.login_user)
self.login_user.assign_perms_to_managed_role("authentik_core.add_group")
self.login_user.assign_perms_to_managed_role("authentik_core.enable_group_superuser")
self.client.force_login(self.login_user)
res = self.client.post(
reverse("authentik_api:group-list"),

View File

@@ -3,7 +3,6 @@
from json import loads
from django.urls import reverse
from guardian.shortcuts import assign_perm
from rest_framework.test import APITestCase
from authentik.core.tests.utils import create_test_admin_user, create_test_user
@@ -48,8 +47,8 @@ class TestImpersonation(APITestCase):
def test_impersonate_global(self):
"""Test impersonation with global permissions"""
new_user = create_test_user()
assign_perm("authentik_core.impersonate", new_user)
assign_perm("authentik_core.view_user", new_user)
new_user.assign_perms_to_managed_role("authentik_core.impersonate")
new_user.assign_perms_to_managed_role("authentik_core.view_user")
self.client.force_login(new_user)
response = self.client.post(
@@ -69,8 +68,8 @@ class TestImpersonation(APITestCase):
def test_impersonate_scoped(self):
"""Test impersonation with scoped permissions"""
new_user = create_test_user()
assign_perm("authentik_core.impersonate", new_user, self.other_user)
assign_perm("authentik_core.view_user", new_user, self.other_user)
new_user.assign_perms_to_managed_role("authentik_core.impersonate", self.other_user)
new_user.assign_perms_to_managed_role("authentik_core.view_user", self.other_user)
self.client.force_login(new_user)
response = self.client.post(

View File

@@ -3,7 +3,7 @@
from django.contrib.auth.models import AnonymousUser
from django.test import TestCase
from django.urls import reverse
from guardian.utils import get_anonymous_user
from guardian.shortcuts import get_anonymous_user
from authentik.core.models import SourceUserMatchingModes, User
from authentik.core.sources.flow_manager import Action

View File

@@ -1,7 +1,6 @@
"""Test Transactional API"""
from django.urls import reverse
from guardian.shortcuts import assign_perm
from rest_framework.test import APITestCase
from authentik.core.models import Application, Group
@@ -16,8 +15,8 @@ class TestTransactionalApplicationsAPI(APITestCase):
def setUp(self) -> None:
self.user = create_test_user()
assign_perm("authentik_core.add_application", self.user)
assign_perm("authentik_providers_oauth2.add_oauth2provider", self.user)
self.user.assign_perms_to_managed_role("authentik_core.add_application")
self.user.assign_perms_to_managed_role("authentik_providers_oauth2.add_oauth2provider")
def test_create_transactional(self):
"""Test transactional Application + provider creation"""
@@ -73,7 +72,7 @@ class TestTransactionalApplicationsAPI(APITestCase):
def test_create_transactional_bindings(self):
"""Test transactional Application + provider creation"""
assign_perm("authentik_policies.add_policybinding", self.user)
self.user.assign_perms_to_managed_role("authentik_policies.add_policybinding")
self.client.force_login(self.user)
uid = generate_id()
group = Group.objects.create(name=generate_id())

View File

@@ -0,0 +1,20 @@
"""user tests"""
from django.test.testcases import TestCase
from authentik.core.models import User
from authentik.lib.generators import generate_id
class TestUsers(TestCase):
"""Test user"""
def test_user_managed_role(self):
"""Test user managed role"""
perm = "authentik_core.view_user"
user = User.objects.create(username=generate_id())
user.assign_perms_to_managed_role(perm)
self.assertEqual(user.roles.count(), 1)
self.assertTrue(user.has_perm(perm))
user.remove_perms_from_managed_role(perm)
self.assertFalse(user.has_perm(perm))

View File

@@ -9,7 +9,6 @@ from cryptography.x509.extensions import SubjectAlternativeName
from cryptography.x509.general_name import DNSName
from django.urls import reverse
from django.utils.timezone import now
from guardian.shortcuts import assign_perm
from rest_framework.test import APITestCase
from authentik.core.api.used_by import DeleteAction
@@ -194,8 +193,8 @@ class TestCrypto(APITestCase):
"""Test certificate export (download)"""
keypair = create_test_cert()
user = create_test_user()
assign_perm("view_certificatekeypair", user, keypair)
assign_perm("view_certificatekeypair_certificate", user, keypair)
user.assign_perms_to_managed_role("view_certificatekeypair", keypair)
user.assign_perms_to_managed_role("view_certificatekeypair_certificate", keypair)
self.client.force_login(user)
response = self.client.get(
reverse(
@@ -218,8 +217,8 @@ class TestCrypto(APITestCase):
"""Test private_key export (download)"""
keypair = create_test_cert()
user = create_test_user()
assign_perm("view_certificatekeypair", user, keypair)
assign_perm("view_certificatekeypair_key", user, keypair)
user.assign_perms_to_managed_role("view_certificatekeypair", keypair)
user.assign_perms_to_managed_role("view_certificatekeypair_key", keypair)
self.client.force_login(user)
response = self.client.get(
reverse(

View File

@@ -3,7 +3,6 @@ from hashlib import sha256
from django.db.models import Model
from django.db.models.signals import post_delete, post_save, pre_delete
from django.dispatch import receiver
from guardian.shortcuts import assign_perm
from authentik.core.models import (
USER_PATH_SYSTEM_PREFIX,
@@ -44,7 +43,7 @@ def ssf_providers_post_save(sender: type[Model], instance: SSFProvider, created:
"path": USER_PATH_PROVIDERS_SSF,
},
)
assign_perm("add_stream", user, instance)
user.assign_perms_to_managed_role("add_stream", instance)
token, token_created = Token.objects.update_or_create(
identifier=identifier,
defaults={

View File

@@ -3,7 +3,6 @@ from unittest.mock import MagicMock, patch
from urllib.parse import quote_plus
from django.urls import reverse
from guardian.shortcuts import assign_perm
from authentik.core.models import User
from authentik.core.tests.utils import (
@@ -92,7 +91,7 @@ class MTLSStageTests(FlowTestCase):
def test_parse_outpost_object(self):
"""Test outposts's format"""
outpost = Outpost.objects.create(name=generate_id(), type=OutpostType.PROXY)
assign_perm("pass_outpost_certificate", outpost.user, self.stage)
outpost.user.assign_perms_to_managed_role("pass_outpost_certificate", self.stage)
with patch(
"authentik.root.middleware.ClientIPMiddleware.get_outpost_user",
MagicMock(return_value=outpost.user),
@@ -109,7 +108,7 @@ class MTLSStageTests(FlowTestCase):
def test_parse_outpost_global(self):
"""Test outposts's format"""
outpost = Outpost.objects.create(name=generate_id(), type=OutpostType.PROXY)
assign_perm("authentik_stages_mtls.pass_outpost_certificate", outpost.user)
outpost.user.assign_perms_to_managed_role("authentik_stages_mtls.pass_outpost_certificate")
with patch(
"authentik.root.middleware.ClientIPMiddleware.get_outpost_user",
MagicMock(return_value=outpost.user),

View File

@@ -20,7 +20,7 @@ from django.utils import timezone
from django.views.debug import SafeExceptionReporterFilter
from geoip2.models import ASN, City
from guardian.conf import settings
from guardian.utils import get_anonymous_user
from guardian.shortcuts import get_anonymous_user
from authentik.blueprints.v1.common import YAMLTag
from authentik.core.models import User

View File

@@ -12,8 +12,6 @@ from django.core.cache import cache
from django.db import IntegrityError, models, transaction
from django.db.models.base import Model
from django.utils.translation import gettext_lazy as _
from guardian.models import UserObjectPermission
from guardian.shortcuts import assign_perm
from model_utils.managers import InheritanceManager
from packaging.version import Version, parse
from rest_framework.serializers import Serializer
@@ -335,8 +333,7 @@ class Outpost(ScheduledModel, SerializerModel, ManagedModel):
# To ensure the user only has the correct permissions, we delete all of them and re-add
# the ones the user needs
with transaction.atomic():
UserObjectPermission.objects.filter(user=user).delete()
user.user_permissions.clear()
user.remove_all_perms_from_managed_role()
for model_or_perm in self.get_required_objects():
if isinstance(model_or_perm, models.Model):
model_or_perm: models.Model
@@ -344,7 +341,7 @@ class Outpost(ScheduledModel, SerializerModel, ManagedModel):
f"{model_or_perm._meta.app_label}.view_{model_or_perm._meta.model_name}"
)
try:
assign_perm(code_name, user, model_or_perm)
user.assign_perms_to_managed_role(code_name, model_or_perm)
except (Permission.DoesNotExist, AttributeError) as exc:
LOGGER.warning(
"permission doesn't exist",
@@ -369,11 +366,11 @@ class Outpost(ScheduledModel, SerializerModel, ManagedModel):
if not permission.exists():
LOGGER.warning("permission doesn't exist", perm=model_or_perm)
continue
user.user_permissions.add(permission.first())
user.assign_perms_to_managed_role(permission.first())
LOGGER.debug(
"Updated service account's permissions",
obj_perms=UserObjectPermission.objects.filter(user=user),
perms=user.user_permissions.all(),
obj_perms=user.get_all_obj_perms_on_managed_role(),
perms=user.get_all_model_perms_on_managed_role(),
)
@property

View File

@@ -3,7 +3,6 @@
from django.apps import apps
from django.contrib.auth.management import create_permissions
from django.test import TestCase
from guardian.models import UserObjectPermission
from authentik.core.tests.utils import create_test_cert, create_test_flow
from authentik.outposts.models import Outpost, OutpostType
@@ -31,14 +30,14 @@ class OutpostTests(TestCase):
)
# Before we add a provider, the user should only have access to the outpost
permissions = UserObjectPermission.objects.filter(user=outpost.user)
permissions = outpost.user.get_all_obj_perms_on_managed_role()
self.assertEqual(len(permissions), 1)
self.assertEqual(permissions[0].object_pk, str(outpost.pk))
# We add a provider, user should only have access to outpost and provider
outpost.providers.add(provider)
provider.refresh_from_db()
permissions = UserObjectPermission.objects.filter(user=outpost.user).order_by(
permissions = outpost.user.get_all_obj_perms_on_managed_role().order_by(
"content_type__model"
)
self.assertEqual(len(permissions), 2)
@@ -49,7 +48,7 @@ class OutpostTests(TestCase):
keypair = create_test_cert()
provider.certificate = keypair
provider.save()
permissions = UserObjectPermission.objects.filter(user=outpost.user).order_by(
permissions = outpost.user.get_all_obj_perms_on_managed_role().order_by(
"content_type__model"
)
self.assertEqual(len(permissions), 3)
@@ -59,6 +58,6 @@ class OutpostTests(TestCase):
# Remove provider from outpost, user should only have access to outpost
outpost.providers.remove(provider)
permissions = UserObjectPermission.objects.filter(user=outpost.user)
permissions = outpost.user.get_all_obj_perms_on_managed_role()
self.assertEqual(len(permissions), 1)
self.assertEqual(permissions[0].object_pk, str(outpost.pk))

View File

@@ -174,7 +174,8 @@ class TestPolicyEngine(TestCase):
def test_engine_group_complex(self):
"""Test more complex group setups"""
group_a = Group.objects.create(name=generate_id())
group_b = Group.objects.create(name=generate_id(), parent=group_a)
group_b = Group.objects.create(name=generate_id())
group_b.parents.add(group_a)
user = create_test_user()
group_b.users.add(user)
pbm = PolicyBindingModel.objects.create()

View File

@@ -3,7 +3,7 @@
from json import dumps
from django_filters.rest_framework import DjangoFilterBackend
from guardian.utils import get_anonymous_user
from guardian.shortcuts import get_anonymous_user
from rest_framework import mixins
from rest_framework.fields import CharField, ListField, SerializerMethodField
from rest_framework.filters import OrderingFilter, SearchFilter

View File

@@ -24,7 +24,6 @@ class InitialPermissionsSerializer(ModelSerializer):
fields = [
"pk",
"name",
"mode",
"role",
"permissions",
"permissions_obj",

View File

@@ -76,7 +76,7 @@ class PermissionFilter(FilterSet):
def filter_role(self, queryset: QuerySet, name, value: Role) -> QuerySet:
"""Filter permissions based on role"""
return queryset.filter(group__role=value)
return queryset.filter(rolemodelpermission__role=value)
class Meta:
model = Permission

View File

@@ -1,11 +1,12 @@
"""common RBAC serializers"""
from django.contrib.auth.models import Permission
from django.db.models import Q, QuerySet
from django.db.transaction import atomic
from django_filters.filters import CharFilter, ChoiceFilter
from django_filters.filterset import FilterSet
from drf_spectacular.utils import OpenApiResponse, extend_schema
from guardian.models import GroupObjectPermission
from guardian.models import RoleModelPermission, RoleObjectPermission
from guardian.shortcuts import assign_perm, remove_perm
from rest_framework.decorators import action
from rest_framework.fields import CharField, ReadOnlyField
@@ -31,47 +32,88 @@ class RoleObjectPermissionSerializer(ModelSerializer):
object_pk = CharField()
class Meta:
model = GroupObjectPermission
model = RoleObjectPermission
fields = ["id", "codename", "model", "app_label", "object_pk", "name"]
class RoleModelPermissionSerializer(ModelSerializer):
"""Role-bound object level permission"""
app_label = ReadOnlyField(source="content_type.app_label")
model = ReadOnlyField(source="content_type.model")
codename = ReadOnlyField(source="permission.codename")
name = ReadOnlyField(source="permission.name")
class Meta:
model = RoleModelPermission
fields = ["id", "codename", "model", "app_label", "name"]
class RoleAssignedObjectPermissionSerializer(PassiveSerializer):
"""Roles assigned object permission serializer"""
role_pk = CharField(source="group.role.pk", read_only=True)
name = CharField(source="group.name", read_only=True)
permissions = RoleObjectPermissionSerializer(
many=True, source="group.groupobjectpermission_set"
role_pk = CharField(source="pk", read_only=True)
name = CharField(read_only=True)
object_permissions = RoleObjectPermissionSerializer(
many=True, source="roleobjectpermission_set"
)
model_permissions = RoleModelPermissionSerializer(many=True, source="rolemodelpermission_set")
class Meta:
model = Role
fields = ["role_pk", "name", "permissions"]
fields = ["role_pk", "name", "object_permissions", "model_permissions"]
class RoleAssignedPermissionFilter(FilterSet):
"""Role Assigned permission filter"""
"""Assigned permission filter"""
model = ChoiceFilter(choices=model_choices(), method="filter_model", required=True)
object_pk = CharFilter(method="filter_object_pk")
def filter_queryset(self, queryset):
queryset = super().filter_queryset(queryset)
data = self.form.cleaned_data
model: str = data["model"]
object_pk: str | None = data.get("object_pk", None)
app, _, model = model.partition(".")
permissions = Permission.objects.filter(
content_type__app_label=app,
content_type__model=model,
)
role_pks_with_model_permission = (
permissions.order_by().values_list("rolemodelpermission__role", flat=True).distinct()
)
role_pks_with_object_permission = []
if object_pk:
role_pks_with_object_permission = (
RoleObjectPermission.objects.filter(
permission__in=permissions,
object_pk=object_pk,
)
.order_by()
.values_list("role", flat=True)
.distinct()
)
return queryset.filter(
Q(pk__in=role_pks_with_model_permission) | Q(pk__in=role_pks_with_object_permission)
)
def filter_model(self, queryset: QuerySet, name, value: str) -> QuerySet:
"""Filter by object type"""
app, _, model = value.partition(".")
return queryset.filter(
Q(
group__permissions__content_type__app_label=app,
group__permissions__content_type__model=model,
)
| Q(
group__groupobjectpermission__permission__content_type__app_label=app,
group__groupobjectpermission__permission__content_type__model=model,
)
).distinct()
# Actual filtering is handled by the above method where both `model` and `object_pk` are
# available. Don't do anything here, this method is only left here to avoid overriding too
# much of filter_queryset.
return queryset
def filter_object_pk(self, queryset: QuerySet, name, value: str) -> QuerySet:
"""Filter by object primary key"""
return queryset.filter(Q(group__groupobjectpermission__object_pk=value)).distinct()
# Actual filtering is handled by the above method where both `model` and `object_pk` are
# available. Don't do anything here, this method is only left here to avoid overriding too
# much of filter_queryset.
return queryset
class RoleAssignedPermissionViewSet(ListModelMixin, GenericViewSet):
@@ -83,6 +125,7 @@ class RoleAssignedPermissionViewSet(ListModelMixin, GenericViewSet):
# which has a required filter that does the heavy lifting
queryset = Role.objects.all()
filterset_class = RoleAssignedPermissionFilter
search_fields = ["name"]
@permission_required("authentik_rbac.assign_role_permissions")
@extend_schema(
@@ -102,7 +145,7 @@ class RoleAssignedPermissionViewSet(ListModelMixin, GenericViewSet):
ids = []
with atomic():
for perm in data.validated_data["permissions"]:
assigned_perm = assign_perm(perm, role.group, data.validated_data["model_instance"])
assigned_perm = assign_perm(perm, role, data.validated_data["model_instance"])
ids.append(PermissionAssignResultSerializer(instance={"id": assigned_perm.pk}).data)
return Response(ids, status=200)
@@ -122,5 +165,5 @@ class RoleAssignedPermissionViewSet(ListModelMixin, GenericViewSet):
data.is_valid(raise_exception=True)
with atomic():
for perm in data.validated_data["permissions"]:
remove_perm(perm, role.group, data.validated_data["model_instance"])
remove_perm(perm, role, data.validated_data["model_instance"])
return Response(status=204)

View File

@@ -1,164 +0,0 @@
"""common RBAC serializers"""
from django.contrib.auth.models import Permission
from django.db.models import Q, QuerySet
from django.db.transaction import atomic
from django_filters.filters import CharFilter, ChoiceFilter
from django_filters.filterset import FilterSet
from drf_spectacular.utils import OpenApiResponse, extend_schema
from guardian.models import UserObjectPermission
from guardian.shortcuts import assign_perm, remove_perm
from rest_framework.decorators import action
from rest_framework.exceptions import ValidationError
from rest_framework.fields import BooleanField, CharField, ReadOnlyField
from rest_framework.mixins import ListModelMixin
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.viewsets import GenericViewSet
from authentik.core.api.groups import PartialUserSerializer
from authentik.core.api.utils import ModelSerializer
from authentik.core.models import Group, User, UserTypes
from authentik.policies.event_matcher.models import model_choices
from authentik.rbac.api.rbac import PermissionAssignResultSerializer, PermissionAssignSerializer
from authentik.rbac.decorators import permission_required
class UserObjectPermissionSerializer(ModelSerializer):
"""User-bound object level permission"""
app_label = ReadOnlyField(source="content_type.app_label")
model = ReadOnlyField(source="content_type.model")
codename = ReadOnlyField(source="permission.codename")
name = ReadOnlyField(source="permission.name")
object_pk = CharField()
class Meta:
model = UserObjectPermission
fields = ["id", "codename", "model", "app_label", "object_pk", "name"]
class UserAssignedObjectPermissionSerializer(PartialUserSerializer):
"""Users assigned object permission serializer"""
permissions = UserObjectPermissionSerializer(many=True, source="userobjectpermission_set")
is_superuser = BooleanField()
class Meta:
model = PartialUserSerializer.Meta.model
fields = PartialUserSerializer.Meta.fields + ["permissions", "is_superuser"]
class UserAssignedPermissionFilter(FilterSet):
"""Assigned permission filter"""
model = ChoiceFilter(choices=model_choices(), method="filter_model", required=True)
object_pk = CharFilter(method="filter_object_pk")
def filter_queryset(self, queryset):
queryset = super().filter_queryset(queryset)
data = self.form.cleaned_data
model: str = data["model"]
object_pk: str | None = data.get("object_pk", None)
app, _, model = model.partition(".")
superuser_pks = (
Group.objects.filter(is_superuser=True).values_list("users", flat=True).distinct()
)
permissions = Permission.objects.filter(
content_type__app_label=app,
content_type__model=model,
)
user_pks_with_model_permission = (
permissions.order_by().values_list("user", flat=True).distinct()
)
user_pks_with_object_permission = []
if object_pk:
user_pks_with_object_permission = (
UserObjectPermission.objects.filter(
permission__in=permissions,
object_pk=object_pk,
)
.order_by()
.values_list("user", flat=True)
.distinct()
)
return queryset.filter(
Q(pk__in=superuser_pks)
| Q(pk__in=user_pks_with_model_permission)
| Q(pk__in=user_pks_with_object_permission)
)
def filter_model(self, queryset: QuerySet, name, value: str) -> QuerySet:
"""Filter by object type"""
# Actual filtering is handled by the above method where both `model` and `object_pk` are
# available. Don't do anything here, this method is only left here to avoid overriding too
# much of filter_queryset.
return queryset
def filter_object_pk(self, queryset: QuerySet, name, value: str) -> QuerySet:
"""Filter by object primary key"""
# Actual filtering is handled by the above method where both `model` and `object_pk` are
# available. Don't do anything here, this method is only left here to avoid overriding too
# much of filter_queryset.
return queryset
class UserAssignedPermissionViewSet(ListModelMixin, GenericViewSet):
"""Get assigned object permissions for a single object"""
serializer_class = UserAssignedObjectPermissionSerializer
ordering = ["username"]
# The filtering is done in the filterset,
# which has a required filter that does the heavy lifting
queryset = User.objects.all().prefetch_related("userobjectpermission_set")
filterset_class = UserAssignedPermissionFilter
@permission_required("authentik_core.assign_user_permissions")
@extend_schema(
request=PermissionAssignSerializer(),
responses={
200: PermissionAssignResultSerializer(many=True),
},
operation_id="rbac_permissions_assigned_by_users_assign",
)
@action(methods=["POST"], detail=True, pagination_class=None, filter_backends=[])
def assign(self, request: Request, *args, **kwargs) -> Response:
"""Assign permission(s) to user"""
user: User = self.get_object()
if user.type == UserTypes.INTERNAL_SERVICE_ACCOUNT:
raise ValidationError("Permissions cannot be assigned to an internal service account.")
data = PermissionAssignSerializer(data=request.data)
data.is_valid(raise_exception=True)
ids = []
with atomic():
for perm in data.validated_data["permissions"]:
assigned_perm = assign_perm(perm, user, data.validated_data["model_instance"])
ids.append(PermissionAssignResultSerializer(instance={"id": assigned_perm.pk}).data)
return Response(ids, status=200)
@permission_required("authentik_core.unassign_user_permissions")
@extend_schema(
request=PermissionAssignSerializer(),
responses={
204: OpenApiResponse(description="Successfully unassigned"),
},
)
@action(methods=["PATCH"], detail=True, pagination_class=None, filter_backends=[])
def unassign(self, request: Request, *args, **kwargs) -> Response:
"""Unassign permission(s) to user. When `object_pk` is set, the permissions
are only assigned to the specific object, otherwise they are assigned globally."""
user: User = self.get_object()
if user.type == UserTypes.INTERNAL_SERVICE_ACCOUNT:
raise ValidationError(
"Permissions cannot be unassigned from an internal service account."
)
data = PermissionAssignSerializer(data=request.data)
data.is_valid(raise_exception=True)
with atomic():
for perm in data.validated_data["permissions"]:
remove_perm(perm, user, data.validated_data["model_instance"])
return Response(status=204)

View File

@@ -3,15 +3,9 @@
from django.apps import apps
from django_filters.filters import UUIDFilter
from django_filters.filterset import FilterSet
from guardian.models import GroupObjectPermission
from guardian.shortcuts import get_objects_for_group
from guardian.models import RoleObjectPermission
from rest_framework.fields import SerializerMethodField
from rest_framework.mixins import (
DestroyModelMixin,
ListModelMixin,
RetrieveModelMixin,
UpdateModelMixin,
)
from rest_framework.mixins import ListModelMixin
from rest_framework.viewsets import GenericViewSet
from authentik.api.pagination import SmallerPagination
@@ -19,21 +13,20 @@ from authentik.rbac.api.rbac_assigned_by_roles import RoleObjectPermissionSerial
class ExtraRoleObjectPermissionSerializer(RoleObjectPermissionSerializer):
"""User permission with additional object-related data"""
"""Role permission with additional object-related data"""
app_label_verbose = SerializerMethodField()
model_verbose = SerializerMethodField()
object_description = SerializerMethodField()
def get_app_label_verbose(self, instance: GroupObjectPermission) -> str:
def get_app_label_verbose(self, instance: RoleObjectPermission) -> str:
"""Get app label from permission's model"""
try:
return apps.get_app_config(instance.content_type.app_label).verbose_name
except LookupError:
return instance.content_type.app_label
def get_model_verbose(self, instance: GroupObjectPermission) -> str:
def get_model_verbose(self, instance: RoleObjectPermission) -> str:
"""Get model label from permission's model"""
try:
return apps.get_model(
@@ -42,18 +35,15 @@ class ExtraRoleObjectPermissionSerializer(RoleObjectPermissionSerializer):
except LookupError:
return f"{instance.content_type.app_label}.{instance.content_type.model}"
def get_object_description(self, instance: GroupObjectPermission) -> str | None:
def get_object_description(self, instance: RoleObjectPermission) -> str | None:
"""Get model description from attached model. This operation takes at least
one additional query, and the description is only shown if the user/role has the
one additional query, and the description is only shown if the role has the
view_ permission on the object"""
app_label = instance.content_type.app_label
model = instance.content_type.model
try:
model_class = apps.get_model(app_label, model)
model_class = instance.content_type.model_class()
except LookupError:
return None
objects = get_objects_for_group(instance.group, f"{app_label}.view_{model}", model_class)
obj = objects.filter(pk=instance.object_pk).first()
obj = model_class.objects.filter(pk=instance.object_pk).first()
if not obj:
return None
return str(obj)
@@ -69,18 +59,14 @@ class ExtraRoleObjectPermissionSerializer(RoleObjectPermissionSerializer):
class RolePermissionFilter(FilterSet):
"""Role permission filter"""
uuid = UUIDFilter("group__role__uuid")
uuid = UUIDFilter("role__uuid")
class RolePermissionViewSet(
ListModelMixin, UpdateModelMixin, RetrieveModelMixin, DestroyModelMixin, GenericViewSet
):
class RolePermissionViewSet(ListModelMixin, GenericViewSet):
"""Get a role's assigned object permissions"""
serializer_class = ExtraRoleObjectPermissionSerializer
ordering = ["group__role__name"]
ordering = ["role__name"]
pagination_class = SmallerPagination
# The filtering is done in the filterset,
# which has a required filter that does the heavy lifting
queryset = GroupObjectPermission.objects.select_related("content_type", "group__role").all()
queryset = RoleObjectPermission.objects.select_related("content_type", "role").all()
filterset_class = RolePermissionFilter

View File

@@ -1,86 +0,0 @@
"""common RBAC serializers"""
from django.apps import apps
from django_filters.filters import NumberFilter
from django_filters.filterset import FilterSet
from guardian.models import UserObjectPermission
from guardian.shortcuts import get_objects_for_user
from rest_framework.fields import SerializerMethodField
from rest_framework.mixins import (
DestroyModelMixin,
ListModelMixin,
RetrieveModelMixin,
UpdateModelMixin,
)
from rest_framework.viewsets import GenericViewSet
from authentik.api.pagination import SmallerPagination
from authentik.rbac.api.rbac_assigned_by_users import UserObjectPermissionSerializer
class ExtraUserObjectPermissionSerializer(UserObjectPermissionSerializer):
"""User permission with additional object-related data"""
app_label_verbose = SerializerMethodField()
model_verbose = SerializerMethodField()
object_description = SerializerMethodField()
def get_app_label_verbose(self, instance: UserObjectPermission) -> str:
"""Get app label from permission's model"""
try:
return apps.get_app_config(instance.content_type.app_label).verbose_name
except LookupError:
return instance.content_type.app_label
def get_model_verbose(self, instance: UserObjectPermission) -> str:
"""Get model label from permission's model"""
try:
return apps.get_model(
instance.content_type.app_label, instance.content_type.model
)._meta.verbose_name
except LookupError:
return f"{instance.content_type.app_label}.{instance.content_type.model}"
def get_object_description(self, instance: UserObjectPermission) -> str | None:
"""Get model description from attached model. This operation takes at least
one additional query, and the description is only shown if the user/role has the
view_ permission on the object"""
app_label = instance.content_type.app_label
model = instance.content_type.model
try:
model_class = apps.get_model(app_label, model)
except LookupError:
return None
objects = get_objects_for_user(instance.user, f"{app_label}.view_{model}", model_class)
obj = objects.filter(pk=instance.object_pk).first()
if not obj:
return None
return str(obj)
class Meta(UserObjectPermissionSerializer.Meta):
fields = UserObjectPermissionSerializer.Meta.fields + [
"app_label_verbose",
"model_verbose",
"object_description",
]
class UserPermissionFilter(FilterSet):
"""User-assigned permission filter"""
user_id = NumberFilter("user__id")
class UserPermissionViewSet(
ListModelMixin, UpdateModelMixin, RetrieveModelMixin, DestroyModelMixin, GenericViewSet
):
"""Get a users's assigned object permissions"""
serializer_class = ExtraUserObjectPermissionSerializer
ordering = ["user__username"]
pagination_class = SmallerPagination
# The filtering is done in the filterset,
# which has a required filter that does the heavy lifting
queryset = UserObjectPermission.objects.select_related("content_type", "user").all()
filterset_class = UserPermissionFilter

View File

@@ -1,19 +1,33 @@
"""RBAC Roles"""
from django.contrib.auth.models import Permission
from django.http import Http404
from django_filters.filters import AllValuesMultipleFilter, BooleanFilter
from django_filters.filterset import FilterSet
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import OpenApiResponse, extend_schema, extend_schema_field
from guardian.shortcuts import get_objects_for_user
from rest_framework.decorators import action
from rest_framework.fields import (
ChoiceField,
IntegerField,
ListField,
)
from rest_framework.permissions import IsAuthenticated
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.viewsets import ModelViewSet
from authentik.blueprints.api import ManagedSerializer
from authentik.blueprints.v1.importer import SERIALIZER_CONTEXT_BLUEPRINT
from authentik.core.api.used_by import UsedByMixin
from authentik.core.api.utils import ModelSerializer
from authentik.core.api.utils import ModelSerializer, PassiveSerializer
from authentik.core.models import User
from authentik.rbac.decorators import permission_required
from authentik.rbac.models import Role, get_permission_choices
class RoleSerializer(ModelSerializer):
class RoleSerializer(ManagedSerializer, ModelSerializer):
"""Role serializer"""
def __init__(self, *args, **kwargs):
@@ -24,19 +38,25 @@ class RoleSerializer(ModelSerializer):
)
def create(self, validated_data: dict) -> Role:
permissions = Permission.objects.filter(
perms_qs = Permission.objects.filter(
codename__in=[x.split(".")[1] for x in validated_data.pop("permissions", [])]
)
).values_list("content_type__app_label", "codename")
perms_list = [f"{ct}.{name}" for ct, name in list(perms_qs)]
instance: Role = super().create(validated_data)
instance.group.permissions.set(permissions)
instance.assign_perms(perms_list)
return instance
def update(self, instance: Role, validated_data: dict) -> Role:
permissions = Permission.objects.filter(
perms_qs = Permission.objects.filter(
codename__in=[x.split(".")[1] for x in validated_data.pop("permissions", [])]
)
).values_list("content_type__app_label", "codename")
perms_list = [f"{ct}.{name}" for ct, name in list(perms_qs)]
instance: Role = super().update(instance, validated_data)
instance.group.permissions.set(permissions)
instance.assign_perms(perms_list)
return instance
class Meta:
@@ -44,6 +64,18 @@ class RoleSerializer(ModelSerializer):
fields = ["pk", "name"]
class RoleFilterSet(FilterSet):
"""Filter for PropertyMapping"""
managed = extend_schema_field(OpenApiTypes.STR)(AllValuesMultipleFilter(field_name="managed"))
managed__isnull = BooleanFilter(field_name="managed", lookup_expr="isnull")
class Meta:
model = Role
fields = ["name", "users", "managed"]
class RoleViewSet(UsedByMixin, ModelViewSet):
"""Role viewset"""
@@ -51,4 +83,69 @@ class RoleViewSet(UsedByMixin, ModelViewSet):
queryset = Role.objects.all()
search_fields = ["name"]
ordering = ["name"]
filterset_fields = ["name"]
filterset_class = RoleFilterSet
class UserAccountSerializerForRole(PassiveSerializer):
"""Account adding/removing operations"""
pk = IntegerField(required=True)
@permission_required("authentik_rbac.change_role")
@extend_schema(
request=UserAccountSerializerForRole,
responses={
204: OpenApiResponse(description="User added"),
404: OpenApiResponse(description="User not found"),
},
)
@action(
detail=True,
methods=["POST"],
pagination_class=None,
filter_backends=[],
permission_classes=[IsAuthenticated],
)
def add_user(self, request: Request, pk: str) -> Response:
"""Add user to role"""
role: Role = self.get_object()
user: User = (
get_objects_for_user(request.user, "authentik_core.view_user")
.filter(
pk=request.data.get("pk"),
)
.first()
)
if not user:
raise Http404
role.users.add(user)
return Response(status=204)
@permission_required("authentik_rbac.change_role")
@extend_schema(
request=UserAccountSerializerForRole,
responses={
204: OpenApiResponse(description="User removed"),
404: OpenApiResponse(description="User not found"),
},
)
@action(
detail=True,
methods=["POST"],
pagination_class=None,
filter_backends=[],
permission_classes=[IsAuthenticated],
)
def remove_user(self, request: Request, pk: str) -> Response:
"""Remove user from role"""
role: Role = self.get_object()
user: User = (
get_objects_for_user(request.user, "authentik_core.view_user")
.filter(
pk=request.data.get("pk"),
)
.first()
)
if not user:
raise Http404
role.users.remove(user)
return Response(status=204)

View File

@@ -5,14 +5,67 @@ from django.db.models import QuerySet
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework.authentication import get_authorization_header
from rest_framework.exceptions import PermissionDenied
from rest_framework.filters import BaseFilterBackend
from rest_framework.request import Request
from rest_framework.views import APIView
from rest_framework_guardian.filters import ObjectPermissionsFilter
from authentik.api.authentication import validate_auth
from authentik.core.models import UserTypes
# Inline fork of https://github.com/rpkilby/django-rest-framework-guardian
# BSD 3-Clause License
#
# Copyright (c) 2018, Ryan P Kilby
# All rights reserved.
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
#
# * Redistributions of source code must retain the above copyright notice, this
# list of conditions and the following disclaimer.
#
# * Redistributions in binary form must reproduce the above copyright notice,
# this list of conditions and the following disclaimer in the documentation
# and/or other materials provided with the distribution.
#
# * Neither the name of the copyright holder nor the names of its
# contributors may be used to endorse or promote products derived from
# this software without specific prior written permission.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
class ObjectPermissionsFilter(BaseFilterBackend):
"""
A filter backend that limits results to those where the requesting user
has read object level permissions.
"""
perm_format = "%(app_label)s.view_%(model_name)s"
def filter_queryset(self, request, queryset, view):
# We want to defer this import until runtime, rather than import-time.
# See https://github.com/encode/django-rest-framework/issues/4608
# (Also see #1624 for why we need to make this import explicitly)
from guardian.shortcuts import get_objects_for_user
user = request.user
permission = self.perm_format % {
"app_label": queryset.model._meta.app_label,
"model_name": queryset.model._meta.model_name,
}
return get_objects_for_user(user, permission, queryset)
class ObjectFilter(ObjectPermissionsFilter):
"""Object permission filter that grants global permission higher priority than
per-object permissions"""

View File

@@ -0,0 +1,33 @@
# Generated by Django 5.1.12 on 2025-10-02 07:16
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("auth", "0012_alter_user_first_name_max_length"),
("authentik_rbac", "0007_alter_systempermission_options"),
]
operations = [
migrations.AlterField(
model_name="role",
name="group",
field=models.OneToOneField(
null=True, on_delete=django.db.models.deletion.CASCADE, to="auth.group"
),
),
migrations.AddField(
model_name="role",
name="managed",
field=models.TextField(
default=None,
help_text="Objects that are managed by authentik. These objects are created and updated automatically. This flag only indicates that an object can be overwritten by migrations. You can still modify the objects via the API, but expect changes to be overwritten in a later update.",
null=True,
unique=True,
verbose_name="Managed by authentik",
),
),
]

View File

@@ -0,0 +1,17 @@
# Generated by Django 5.2.8 on 2025-11-28 16:06
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("authentik_rbac", "0008_alter_role_group"),
]
operations = [
migrations.RemoveField(
model_name="initialpermissions",
name="mode",
),
]

View File

@@ -7,9 +7,10 @@ from django.contrib.auth.models import Permission
from django.db import models
from django.db.transaction import atomic
from django.utils.translation import gettext_lazy as _
from guardian.shortcuts import assign_perm
from guardian.shortcuts import assign_perm, remove_perm
from rest_framework.serializers import BaseSerializer
from authentik.blueprints.models import ManagedModel
from authentik.lib.models import SerializerModel
from authentik.lib.utils.reflection import get_apps
@@ -31,7 +32,7 @@ def get_permission_choices():
)
class Role(SerializerModel):
class Role(SerializerModel, ManagedModel):
"""RBAC role, which can have different permissions (both global and per-object) attached
to it."""
@@ -45,19 +46,11 @@ class Role(SerializerModel):
# 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)
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)
def assign_permission(self, *perms: str, obj: models.Model | None = None):
"""Assign permission to role, can handle multiple permissions,
but when assigning multiple permissions to an object the permissions
must all belong to the object given"""
with atomic():
for perm in perms:
assign_perm(perm, self.group, obj)
@property
def serializer(self) -> type[BaseSerializer]:
from authentik.rbac.api.roles import RoleSerializer
@@ -75,19 +68,39 @@ class Role(SerializerModel):
("unassign_role_permissions", _("Can unassign permissions from roles")),
]
def assign_perms(
self,
perms: str | list[str] | Permission | list[Permission],
obj: models.Model | None = None,
):
"""Assign permission to role, can handle multiple permissions,
but when assigning multiple permissions to an object the permissions
must all belong to the object given"""
if not isinstance(perms, list):
perms = [perms]
with atomic():
for perm in perms:
assign_perm(perm, self, obj)
class InitialPermissionsMode(models.TextChoices):
"""Determines which entity the initial permissions are assigned to."""
USER = "user", _("User")
ROLE = "role", _("Role")
def remove_perms(
self,
perms: str | list[str] | Permission | list[Permission],
obj: models.Model | None = None,
):
"""Assign permission to role, can handle multiple permissions,
but when assigning multiple permissions to an object the permissions
must all belong to the object given"""
if isinstance(perms, str):
perms = [perms]
with atomic():
for perm in perms:
remove_perm(perm, self, obj)
class InitialPermissions(SerializerModel):
"""Assigns permissions for newly created objects."""
name = models.TextField(max_length=150, unique=True)
mode = models.CharField(choices=InitialPermissionsMode.choices)
role = models.ForeignKey(Role, on_delete=models.CASCADE)
permissions = models.ManyToManyField(Permission, blank=True)
@@ -98,7 +111,7 @@ class InitialPermissions(SerializerModel):
return InitialPermissionsSerializer
def __str__(self) -> str:
return f"Initial Permissions for Role #{self.role_id}, applying to #{self.mode}"
return f"Initial Permissions for Role #{self.role_id}."
class Meta:
verbose_name = _("Initial Permissions")

View File

@@ -7,7 +7,7 @@ from rest_framework.permissions import BasePermission, DjangoObjectPermissions
from rest_framework.request import Request
from structlog.stdlib import get_logger
from authentik.rbac.models import InitialPermissions, InitialPermissionsMode
from authentik.rbac.models import InitialPermissions
LOGGER = get_logger()
@@ -64,20 +64,15 @@ def HasPermission(*perm: str) -> type[BasePermission]:
# The author of this function isn't proficient/patient enough to do it.
def assign_initial_permissions(user, instance: Model):
# Performance here should not be an issue, but if needed, there are many optimization routes
initial_permissions_list = InitialPermissions.objects.filter(role__group__in=user.groups.all())
initial_permissions_list = InitialPermissions.objects.filter(role__in=user.all_roles())
for initial_permissions in initial_permissions_list:
for permission in initial_permissions.permissions.all():
if permission.content_type != ContentType.objects.get_for_model(instance):
continue
assign_to = (
user
if initial_permissions.mode == InitialPermissionsMode.USER
else initial_permissions.role.group
)
LOGGER.debug(
"Adding initial permission",
initial_permission=permission,
subject=assign_to,
subject=initial_permissions.role,
object=instance,
)
assign_perm(permission, assign_to, instance)
assign_perm(permission, initial_permissions.role, instance)

View File

@@ -1,87 +0,0 @@
"""rbac signals"""
from django.contrib.auth.models import Group as DjangoGroup
from django.db.models.signals import m2m_changed, pre_delete, pre_save
from django.db.transaction import atomic
from django.dispatch import receiver
from rest_framework.exceptions import ValidationError
from structlog.stdlib import get_logger
from authentik.core.models import Group
from authentik.rbac.models import Role
LOGGER = get_logger()
@receiver(pre_save, sender=Role)
def rbac_role_pre_save(sender: type[Role], instance: Role, **_):
"""Ensure role has a group object created for it"""
if hasattr(instance, "group"):
return
group, _ = DjangoGroup.objects.get_or_create(name=instance.name)
instance.group = group
@receiver(pre_delete, sender=Role)
@receiver(pre_delete, sender=Group)
def rbac_pre_delete_cleanup(sender: type[Group] | type[Role], instance: Group | Role, **_):
"""RBAC: remove permissions from users when a group is deleted"""
if sender == Group:
for role in instance.roles.all():
role.group.user_set.clear()
if sender == Role:
instance.group.user_set.clear()
@receiver(m2m_changed, sender=Group.roles.through)
def rbac_group_role_m2m(
sender: type[Group], action: str, instance: Group, reverse: bool, pk_set: set, **_
):
"""RBAC: Sync group members into roles when roles are assigned"""
if action == "pre_add":
# Validation: check that any of the added roles are not used in any other groups
if Group.objects.filter(roles__in=pk_set).exclude(pk=instance.pk).exists():
raise ValidationError("Roles can only be used with a single group.")
if action not in ["post_add", "post_remove", "post_clear"]:
return
with atomic():
group_users = (
Group.objects.filter(group_uuid=instance.group_uuid)
.with_children_recursive()
.exclude(users__isnull=True)
.values_list("users", flat=True)
)
for role in Role.objects.filter(pk__in=pk_set):
if action == "post_add":
role.group.user_set.add(*group_users)
# Role(s) in pk_set were removed from group, so remove the users that we added
if action == "post_remove":
role.group.user_set.remove(*group_users)
LOGGER.debug("Updated users in group", group=instance, direction=action, users=group_users)
@receiver(m2m_changed, sender=Group.users.through)
def rbac_group_users_m2m(
sender: type[Group], action: str, instance: Group, pk_set: set, reverse: bool, **_
):
"""Handle Group/User m2m and mirror it to roles"""
if action not in ["post_add", "post_remove"]:
return
# reverse: instance is a Group, pk_set is a list of user pks
# non-reverse: instance is a User, pk_set is a list of groups
with atomic():
if reverse:
for role in instance.roles.all():
role: Role
if action == "post_add":
role.group.user_set.add(*pk_set)
elif action == "post_remove":
role.group.user_set.remove(*pk_set)
else:
for group in Group.objects.filter(pk__in=pk_set):
for role in group.roles.all():
role: Role
if action == "post_add":
role.group.user_set.add(instance)
elif action == "post_remove":
role.group.user_set.remove(instance)

View File

@@ -15,6 +15,9 @@ class TestRBACRoleAPI(APITestCase):
"""Test RoleAssignedPermissionViewSet api"""
def setUp(self) -> None:
# Make sure we have no roles to start with (e.g. Read-only from blueprints)
Role.objects.all().delete()
self.superuser = create_test_admin_user()
self.user = create_test_user()
@@ -29,7 +32,7 @@ class TestRBACRoleAPI(APITestCase):
name=generate_id(),
created_by=self.superuser,
)
self.role.assign_permission("authentik_stages_invitation.view_invitation", obj=inv)
self.role.assign_perms("authentik_stages_invitation.view_invitation", obj=inv)
# self.user doesn't have permissions to see their (object) permissions
self.client.force_login(self.superuser)
res = self.client.get(
@@ -107,7 +110,7 @@ class TestRBACRoleAPI(APITestCase):
def test_unassign_global(self):
"""Test permission unassign"""
self.role.assign_permission("authentik_stages_invitation.view_invitation")
self.role.assign_perms("authentik_stages_invitation.view_invitation")
self.client.force_login(self.superuser)
res = self.client.patch(
reverse(
@@ -129,7 +132,7 @@ class TestRBACRoleAPI(APITestCase):
name=generate_id(),
created_by=self.superuser,
)
self.role.assign_permission("authentik_stages_invitation.view_invitation", obj=inv)
self.role.assign_perms("authentik_stages_invitation.view_invitation", obj=inv)
self.client.force_login(self.superuser)
res = self.client.patch(
reverse(

View File

@@ -1,199 +0,0 @@
"""Test UserAssignedPermissionViewSet api"""
from django.urls import reverse
from guardian.shortcuts import assign_perm
from rest_framework.test import APITestCase
from authentik.core.models import Group, User, UserTypes
from authentik.core.tests.utils import create_test_admin_user, create_test_user
from authentik.lib.generators import generate_id
from authentik.rbac.api.rbac_assigned_by_users import UserAssignedObjectPermissionSerializer
from authentik.rbac.models import Role
from authentik.stages.invitation.models import Invitation
class TestRBACUserAPI(APITestCase):
"""Test UserAssignedPermissionViewSet api"""
def setUp(self) -> None:
self.superuser = create_test_admin_user()
self.user = create_test_user()
self.role = Role.objects.create(name=generate_id())
self.group = Group.objects.create(name=generate_id())
self.group.roles.add(self.role)
self.group.users.add(self.user)
def test_filter_assigned(self):
"""Test UserAssignedPermissionViewSet's filters"""
User.objects.filter(username="akadmin").delete()
inv = Invitation.objects.create(
name=generate_id(),
created_by=self.superuser,
)
assign_perm("authentik_stages_invitation.view_invitation", self.user, inv)
# self.user doesn't have permissions to see their (object) permissions
self.client.force_login(self.superuser)
res = self.client.get(
reverse("authentik_api:permissions-assigned-by-users-list"),
{
"model": "authentik_stages_invitation.invitation",
"object_pk": str(inv.pk),
"ordering": "pk",
},
)
self.assertEqual(res.status_code, 200)
self.assertJSONEqual(
res.content.decode(),
{
"autocomplete": {},
"pagination": {
"next": 0,
"previous": 0,
"count": 2,
"current": 1,
"total_pages": 1,
"start_index": 1,
"end_index": 2,
},
"results": sorted(
[
UserAssignedObjectPermissionSerializer(instance=self.user).data,
UserAssignedObjectPermissionSerializer(instance=self.superuser).data,
],
key=lambda u: u["pk"],
),
},
)
def test_assign_global(self):
"""Test permission assign"""
self.client.force_login(self.superuser)
res = self.client.post(
reverse(
"authentik_api:permissions-assigned-by-users-assign",
kwargs={
"pk": self.user.pk,
},
),
{
"permissions": ["authentik_stages_invitation.view_invitation"],
},
)
self.assertEqual(res.status_code, 200)
self.assertTrue(self.user.has_perm("authentik_stages_invitation.view_invitation"))
def test_assign_global_internal_sa(self):
"""Test permission assign (to internal service account)"""
self.client.force_login(self.superuser)
self.user.type = UserTypes.INTERNAL_SERVICE_ACCOUNT
self.user.save()
res = self.client.post(
reverse(
"authentik_api:permissions-assigned-by-users-assign",
kwargs={
"pk": self.user.pk,
},
),
{
"permissions": ["authentik_stages_invitation.view_invitation"],
},
)
self.assertEqual(res.status_code, 400)
self.assertFalse(self.user.has_perm("authentik_stages_invitation.view_invitation"))
def test_assign_object(self):
"""Test permission assign (object)"""
inv = Invitation.objects.create(
name=generate_id(),
created_by=self.superuser,
)
self.client.force_login(self.superuser)
res = self.client.post(
reverse(
"authentik_api:permissions-assigned-by-users-assign",
kwargs={
"pk": self.user.pk,
},
),
{
"permissions": ["authentik_stages_invitation.view_invitation"],
"model": "authentik_stages_invitation.invitation",
"object_pk": str(inv.pk),
},
)
self.assertEqual(res.status_code, 200)
self.assertTrue(
self.user.has_perm(
"authentik_stages_invitation.view_invitation",
inv,
)
)
def test_unassign_global(self):
"""Test permission unassign"""
assign_perm("authentik_stages_invitation.view_invitation", self.user)
self.client.force_login(self.superuser)
res = self.client.patch(
reverse(
"authentik_api:permissions-assigned-by-users-unassign",
kwargs={
"pk": self.user.pk,
},
),
{
"permissions": ["authentik_stages_invitation.view_invitation"],
},
)
self.assertEqual(res.status_code, 204)
self.assertFalse(self.user.has_perm("authentik_stages_invitation.view_invitation"))
def test_unassign_global_internal_sa(self):
"""Test permission unassign (from internal service account)"""
self.client.force_login(self.superuser)
self.user.type = UserTypes.INTERNAL_SERVICE_ACCOUNT
self.user.save()
assign_perm("authentik_stages_invitation.view_invitation", self.user)
self.client.force_login(self.superuser)
res = self.client.patch(
reverse(
"authentik_api:permissions-assigned-by-users-unassign",
kwargs={
"pk": self.user.pk,
},
),
{
"permissions": ["authentik_stages_invitation.view_invitation"],
},
)
self.assertEqual(res.status_code, 400)
self.assertTrue(self.user.has_perm("authentik_stages_invitation.view_invitation"))
def test_unassign_object(self):
"""Test permission unassign (object)"""
inv = Invitation.objects.create(
name=generate_id(),
created_by=self.superuser,
)
assign_perm("authentik_stages_invitation.view_invitation", self.user, inv)
self.client.force_login(self.superuser)
res = self.client.patch(
reverse(
"authentik_api:permissions-assigned-by-users-unassign",
kwargs={
"pk": self.user.pk,
},
),
{
"permissions": ["authentik_stages_invitation.view_invitation"],
"model": "authentik_stages_invitation.invitation",
"object_pk": str(inv.pk),
},
)
self.assertEqual(res.status_code, 204)
self.assertFalse(
self.user.has_perm(
"authentik_stages_invitation.view_invitation",
inv,
)
)

View File

@@ -26,7 +26,7 @@ class TestAPIPerms(APITestCase):
def test_list_simple(self):
"""Test list (single object, role has global permission)"""
self.client.force_login(self.user)
self.role.assign_permission("authentik_stages_invitation.view_invitation")
self.role.assign_perms("authentik_stages_invitation.view_invitation")
Invitation.objects.all().delete()
inv = Invitation.objects.create(
@@ -67,7 +67,7 @@ class TestAPIPerms(APITestCase):
name=generate_id(),
created_by=self.superuser,
)
self.role.assign_permission("authentik_stages_invitation.view_invitation", obj=inv2)
self.role.assign_perms("authentik_stages_invitation.view_invitation", obj=inv2)
res = self.client.get(reverse("authentik_api:invitation-list"))
self.assertEqual(res.status_code, 200)
@@ -104,7 +104,7 @@ class TestAPIPerms(APITestCase):
def test_create_simple(self):
"""Test create with permission"""
self.client.force_login(self.user)
self.role.assign_permission("authentik_stages_invitation.add_invitation")
self.role.assign_perms("authentik_stages_invitation.add_invitation")
res = self.client.post(
reverse("authentik_api:invitation-list"),
data={
@@ -128,8 +128,8 @@ class TestAPIPerms(APITestCase):
"""Test update with permission"""
self.client.force_login(self.user)
inv = Invitation.objects.create(name=generate_id(), created_by=self.superuser)
self.role.assign_permission("authentik_stages_invitation.view_invitation", obj=inv)
self.role.assign_permission("authentik_stages_invitation.change_invitation", obj=inv)
self.role.assign_perms("authentik_stages_invitation.view_invitation", obj=inv)
self.role.assign_perms("authentik_stages_invitation.change_invitation", obj=inv)
res = self.client.patch(
reverse("authentik_api:invitation-detail", kwargs={"pk": inv.pk}),
data={

View File

@@ -1,7 +1,7 @@
"""Test RolePermissionViewSet api"""
from django.urls import reverse
from guardian.models import GroupObjectPermission
from guardian.models import RoleObjectPermission
from rest_framework.test import APITestCase
from authentik.core.models import Group
@@ -30,7 +30,7 @@ class TestRBACPermissionRoles(APITestCase):
name=generate_id(),
created_by=self.superuser,
)
self.role.assign_permission("authentik_stages_invitation.view_invitation", obj=inv)
self.role.assign_perms("authentik_stages_invitation.view_invitation", obj=inv)
res = self.client.get(reverse("authentik_api:permissions-roles-list"))
self.assertEqual(res.status_code, 200)
@@ -41,7 +41,7 @@ class TestRBACPermissionRoles(APITestCase):
name=generate_id(),
created_by=self.superuser,
)
self.role.assign_permission("authentik_stages_invitation.view_invitation", obj=inv)
self.role.assign_perms("authentik_stages_invitation.view_invitation", obj=inv)
res = self.client.get(
reverse("authentik_api:permissions-roles-list") + f"?uuid={self.role.pk}"
)
@@ -60,7 +60,7 @@ class TestRBACPermissionRoles(APITestCase):
},
"results": [
{
"id": GroupObjectPermission.objects.filter(object_pk=inv.pk).first().pk,
"id": RoleObjectPermission.objects.filter(object_pk=inv.pk).first().pk,
"codename": "view_invitation",
"model": "invitation",
"app_label": "authentik_stages_invitation",

View File

@@ -1,76 +0,0 @@
"""Test UserPermissionViewSet api"""
from django.urls import reverse
from guardian.models import UserObjectPermission
from guardian.shortcuts import assign_perm
from rest_framework.test import APITestCase
from authentik.core.models import Group
from authentik.core.tests.utils import create_test_admin_user, create_test_user
from authentik.lib.generators import generate_id
from authentik.rbac.models import Role
from authentik.stages.invitation.models import Invitation
class TestRBACPermissionUsers(APITestCase):
"""Test UserPermissionViewSet api"""
def setUp(self) -> None:
self.superuser = create_test_admin_user()
self.user = create_test_user()
self.role = Role.objects.create(name=generate_id())
self.group = Group.objects.create(name=generate_id())
self.group.roles.add(self.role)
self.group.users.add(self.user)
def test_list(self):
"""Test list of all permissions"""
self.client.force_login(self.superuser)
inv = Invitation.objects.create(
name=generate_id(),
created_by=self.superuser,
)
assign_perm("authentik_stages_invitation.view_invitation", self.user, inv)
res = self.client.get(reverse("authentik_api:permissions-users-list"))
self.assertEqual(res.status_code, 200)
def test_list_role(self):
"""Test list of all permissions"""
self.client.force_login(self.superuser)
inv = Invitation.objects.create(
name=generate_id(),
created_by=self.superuser,
)
assign_perm("authentik_stages_invitation.view_invitation", self.user, inv)
res = self.client.get(
reverse("authentik_api:permissions-users-list") + f"?user_id={self.user.pk}"
)
self.assertEqual(res.status_code, 200)
self.assertJSONEqual(
res.content,
{
"pagination": {
"next": 0,
"previous": 0,
"count": 1,
"current": 1,
"total_pages": 1,
"start_index": 1,
"end_index": 1,
},
"results": [
{
"id": UserObjectPermission.objects.filter(object_pk=inv.pk).first().pk,
"codename": "view_invitation",
"model": "invitation",
"app_label": "authentik_stages_invitation",
"object_pk": str(inv.pk),
"name": "Can view Invitation",
"app_label_verbose": "authentik Stages.Invitation",
"model_verbose": "Invitation",
"object_description": str(inv),
}
],
},
)

View File

@@ -1,6 +1,5 @@
"""test decorators api"""
from guardian.shortcuts import assign_perm
from rest_framework.decorators import action
from rest_framework.request import Request
from rest_framework.response import Response
@@ -42,8 +41,8 @@ class TestAPIDecorators(APITestCase):
def test_obj_perm_global(self):
"""Test object perm successful (global)"""
assign_perm("authentik_core.view_application", self.user)
assign_perm("authentik_events.view_event", self.user)
self.user.assign_perms_to_managed_role("authentik_core.view_application")
self.user.assign_perms_to_managed_role("authentik_events.view_event")
app = Application.objects.create(name=generate_id(), slug=generate_id())
request = self.request_factory.get("", user=self.user)
response = MVS.as_view({"get": "test"})(request, slug=app.slug)
@@ -51,9 +50,9 @@ class TestAPIDecorators(APITestCase):
def test_obj_perm_scoped(self):
"""Test object perm successful (scoped)"""
assign_perm("authentik_events.view_event", self.user)
self.user.assign_perms_to_managed_role("authentik_events.view_event")
app = Application.objects.create(name=generate_id(), slug=generate_id())
assign_perm("authentik_core.view_application", self.user, app)
self.user.assign_perms_to_managed_role("authentik_core.view_application", app)
request = self.request_factory.get("", user=self.user)
response = MVS.as_view({"get": "test"})(request, slug=app.slug)
self.assertEqual(response.status_code, 200)
@@ -61,7 +60,7 @@ class TestAPIDecorators(APITestCase):
def test_other_perm_denied(self):
"""Test other perm denied"""
app = Application.objects.create(name=generate_id(), slug=generate_id())
assign_perm("authentik_core.view_application", self.user, app)
self.user.assign_perms_to_managed_role("authentik_core.view_application", app)
request = self.request_factory.get("", user=self.user)
response = MVS.as_view({"get": "test"})(request, slug=app.slug)
self.assertEqual(response.status_code, 403)

View File

@@ -1,14 +1,13 @@
"""Test InitialPermissions"""
from django.contrib.auth.models import Permission
from guardian.shortcuts import assign_perm
from rest_framework.reverse import reverse
from rest_framework.test import APITestCase
from authentik.core.models import Group
from authentik.core.tests.utils import create_test_user
from authentik.lib.generators import generate_id
from authentik.rbac.models import InitialPermissions, InitialPermissionsMode, Role
from authentik.rbac.models import InitialPermissions, Role
from authentik.stages.dummy.models import DummyStage
@@ -31,13 +30,11 @@ class TestInitialPermissions(APITestCase):
self.different_group.roles.add(self.different_role)
self.different_group.users.add(self.different_role_user)
self.ip = InitialPermissions.objects.create(
name=generate_id(), mode=InitialPermissionsMode.USER, role=self.role
)
self.ip = InitialPermissions.objects.create(name=generate_id(), role=self.role)
self.view_role = Permission.objects.filter(codename="view_role").first()
self.ip.permissions.add(self.view_role)
assign_perm("authentik_rbac.add_role", self.user)
self.user.assign_perms_to_managed_role("authentik_rbac.add_role")
self.client.force_login(self.user)
def test_different_role(self):
@@ -52,7 +49,7 @@ class TestInitialPermissions(APITestCase):
def test_different_model(self):
"""InitialPermissions for different model does nothing"""
assign_perm("authentik_stages_dummy.add_dummystage", self.user)
self.user.assign_perms_to_managed_role("authentik_stages_dummy.add_dummystage")
self.client.post(
reverse("authentik_api:stages-dummy-list"), {"name": "test-stage", "throw-error": False}
@@ -63,17 +60,8 @@ class TestInitialPermissions(APITestCase):
stage = DummyStage.objects.filter(name="test-stage").first()
self.assertFalse(self.user.has_perm("authentik_stages_dummy.view_dummystage", stage))
def test_mode_user(self):
"""InitialPermissions adds user permission in user mode"""
self.client.post(reverse("authentik_api:roles-list"), {"name": "test-role"})
role = Role.objects.filter(name="test-role").first()
self.assertTrue(self.user.has_perm("authentik_rbac.view_role", role))
self.assertFalse(self.same_role_user.has_perm("authentik_rbac.view_role", role))
def test_mode_role(self):
"""InitialPermissions adds role permission in role mode"""
self.ip.mode = InitialPermissionsMode.ROLE
def test_single_permission(self):
"""InitialPermissions adds role permission"""
self.ip.save()
self.client.post(reverse("authentik_api:roles-list"), {"name": "test-role"})
@@ -94,12 +82,11 @@ class TestInitialPermissions(APITestCase):
self.assertTrue(self.user.has_perm("authentik_rbac.change_role", role))
def test_permissions_separated_by_role(self):
"""When the triggering user is part of two different roles with InitialPermissions in role
mode, it only adds permissions to the relevant role."""
self.ip.mode = InitialPermissionsMode.ROLE
"""When the triggering user is part of two different roles with InitialPermissions it only
adds permissions to the relevant role."""
self.ip.save()
different_ip = InitialPermissions.objects.create(
name=generate_id(), mode=InitialPermissionsMode.ROLE, role=self.different_role
name=generate_id(), role=self.different_role
)
change_role = Permission.objects.filter(codename="change_role").first()
different_ip.permissions.add(change_role)

View File

@@ -1,6 +1,6 @@
"""RBAC role tests"""
from rest_framework.exceptions import ValidationError
from django.urls import reverse
from rest_framework.test import APITestCase
from authentik.core.models import Group, User
@@ -12,110 +12,147 @@ from authentik.rbac.models import Role
class TestRoles(APITestCase):
"""Test roles"""
def setUp(self) -> None:
self.login_user = create_test_user()
self.user = create_test_user()
def test_role_create(self):
"""Test creation"""
user = create_test_user()
group = Group.objects.create(name=generate_id())
role = Role.objects.create(name=generate_id())
role.save()
role.assign_permission("authentik_core.view_application")
role.assign_perms("authentik_core.view_application")
group.roles.add(role)
group.users.add(user)
self.assertEqual(list(role.group.user_set.all()), [user])
self.assertTrue(user.has_perm("authentik_core.view_application"))
group.users.add(self.user)
self.assertTrue(self.user.has_perm("authentik_core.view_application"))
def test_role_create_add_reverse(self):
"""Test creation (add user in reverse)"""
user = create_test_user()
group = Group.objects.create(name=generate_id())
role = Role.objects.create(name=generate_id())
role.assign_permission("authentik_core.view_application")
role.assign_perms("authentik_core.view_application")
group.roles.add(role)
user.ak_groups.add(group)
self.assertEqual(list(role.group.user_set.all()), [user])
self.assertTrue(user.has_perm("authentik_core.view_application"))
self.user.ak_groups.add(group)
self.assertTrue(self.user.has_perm("authentik_core.view_application"))
def test_remove_group_delete(self):
"""Test creation and remove"""
user = create_test_user()
group = Group.objects.create(name=generate_id())
role = Role.objects.create(name=generate_id())
role.assign_permission("authentik_core.view_application")
role.assign_perms("authentik_core.view_application")
group.roles.add(role)
group.users.add(user)
self.assertEqual(list(role.group.user_set.all()), [user])
self.assertTrue(user.has_perm("authentik_core.view_application"))
group.users.add(self.user)
self.assertTrue(self.user.has_perm("authentik_core.view_application"))
group.delete()
user = User.objects.get(username=user.username)
user = User.objects.get(username=self.user.username)
self.assertFalse(user.has_perm("authentik_core.view_application"))
self.assertEqual(list(role.group.user_set.all()), [])
def test_remove_roles_remove(self):
"""Test assigning permission to role, then removing role from group"""
user = create_test_user()
group = Group.objects.create(name=generate_id())
role = Role.objects.create(name=generate_id())
role.assign_permission("authentik_core.view_application")
role.assign_perms("authentik_core.view_application")
group.roles.add(role)
group.users.add(user)
self.assertEqual(list(role.group.user_set.all()), [user])
self.assertTrue(user.has_perm("authentik_core.view_application"))
group.users.add(self.user)
self.assertTrue(self.user.has_perm("authentik_core.view_application"))
group.roles.remove(role)
user = User.objects.get(username=user.username)
user = User.objects.get(username=self.user.username)
self.assertFalse(user.has_perm("authentik_core.view_application"))
self.assertEqual(list(role.group.user_set.all()), [])
def test_remove_role_delete(self):
"""Test assigning permissions to role, then removing role"""
user = create_test_user()
group = Group.objects.create(name=generate_id())
role = Role.objects.create(name=generate_id())
role.assign_permission("authentik_core.view_application")
role.assign_perms("authentik_core.view_application")
group.roles.add(role)
group.users.add(user)
self.assertEqual(list(role.group.user_set.all()), [user])
self.assertTrue(user.has_perm("authentik_core.view_application"))
group.users.add(self.user)
self.assertTrue(self.user.has_perm("authentik_core.view_application"))
role.delete()
user = User.objects.get(username=user.username)
user = User.objects.get(username=self.user.username)
self.assertFalse(user.has_perm("authentik_core.view_application"))
self.assertEqual(list(role.group.user_set.all()), [])
def test_role_assign_twice(self):
"""Test assigning role to two groups"""
group1 = Group.objects.create(name=generate_id())
group2 = Group.objects.create(name=generate_id())
role = Role.objects.create(name=generate_id())
role.assign_permission("authentik_core.view_application")
group1.roles.add(role)
with self.assertRaises(ValidationError):
group2.roles.add(role)
def test_remove_users_remove(self):
"""Test assigning permission to role, then removing user from group"""
user = create_test_user()
group = Group.objects.create(name=generate_id())
role = Role.objects.create(name=generate_id())
role.assign_permission("authentik_core.view_application")
role.assign_perms("authentik_core.view_application")
group.roles.add(role)
group.users.add(user)
self.assertEqual(list(role.group.user_set.all()), [user])
self.assertTrue(user.has_perm("authentik_core.view_application"))
group.users.remove(user)
user = User.objects.get(username=user.username)
group.users.add(self.user)
self.assertTrue(self.user.has_perm("authentik_core.view_application"))
group.users.remove(self.user)
user = User.objects.get(username=self.user.username)
self.assertFalse(user.has_perm("authentik_core.view_application"))
self.assertEqual(list(role.group.user_set.all()), [])
def test_remove_users_remove_reverse(self):
"""Test assigning permission to role, then removing user from group in reverse"""
user = create_test_user()
group = Group.objects.create(name=generate_id())
role = Role.objects.create(name=generate_id())
role.assign_permission("authentik_core.view_application")
role.assign_perms("authentik_core.view_application")
group.roles.add(role)
group.users.add(user)
self.assertEqual(list(role.group.user_set.all()), [user])
self.assertTrue(user.has_perm("authentik_core.view_application"))
user.ak_groups.remove(group)
user = User.objects.get(username=user.username)
group.users.add(self.user)
self.assertTrue(self.user.has_perm("authentik_core.view_application"))
self.user.ak_groups.remove(group)
user = User.objects.get(username=self.user.username)
self.assertFalse(user.has_perm("authentik_core.view_application"))
self.assertEqual(list(role.group.user_set.all()), [])
def test_add_user_api(self):
"""Test add_user"""
role = Role.objects.create(name=generate_id())
self.login_user.assign_perms_to_managed_role("authentik_core.view_user")
self.login_user.assign_perms_to_managed_role("authentik_rbac.change_role", role)
self.client.force_login(self.login_user)
res = self.client.post(
reverse("authentik_api:roles-add-user", kwargs={"pk": role.pk}),
data={
"pk": self.user.pk,
},
)
self.assertEqual(res.status_code, 204)
role.refresh_from_db()
self.assertEqual(list(role.users.all()), [self.user])
def test_add_user_api_404(self):
"""Test add_user"""
role = Role.objects.create(name=generate_id())
self.login_user.assign_perms_to_managed_role("authentik_core.view_user")
self.login_user.assign_perms_to_managed_role("authentik_rbac.change_role", role)
self.client.force_login(self.login_user)
res = self.client.post(
reverse("authentik_api:roles-add-user", kwargs={"pk": role.pk}),
data={
"pk": self.user.pk + 3,
},
)
self.assertEqual(res.status_code, 404)
def test_remove_user_api(self):
"""Test remove_user"""
role = Role.objects.create(name=generate_id())
self.login_user.assign_perms_to_managed_role("authentik_core.view_user")
self.login_user.assign_perms_to_managed_role("authentik_rbac.change_role", role)
role.users.add(self.user)
self.client.force_login(self.login_user)
res = self.client.post(
reverse("authentik_api:roles-remove-user", kwargs={"pk": role.pk}),
data={
"pk": self.user.pk,
},
)
self.assertEqual(res.status_code, 204)
role.refresh_from_db()
self.assertEqual(list(role.users.all()), [])
def test_remove_user_404_api(self):
"""Test remove_user"""
role = Role.objects.create(name=generate_id())
self.login_user.assign_perms_to_managed_role("authentik_core.view_user")
self.login_user.assign_perms_to_managed_role("authentik_rbac.change_role", role)
role.users.add(self.user)
self.client.force_login(self.login_user)
res = self.client.post(
reverse("authentik_api:roles-remove-user", kwargs={"pk": role.pk}),
data={
"pk": self.user.pk + 3,
},
)
self.assertEqual(res.status_code, 404)

View File

@@ -3,23 +3,15 @@
from authentik.rbac.api.initial_permissions import InitialPermissionsViewSet
from authentik.rbac.api.rbac import RBACPermissionViewSet
from authentik.rbac.api.rbac_assigned_by_roles import RoleAssignedPermissionViewSet
from authentik.rbac.api.rbac_assigned_by_users import UserAssignedPermissionViewSet
from authentik.rbac.api.rbac_roles import RolePermissionViewSet
from authentik.rbac.api.rbac_users import UserPermissionViewSet
from authentik.rbac.api.roles import RoleViewSet
api_urlpatterns = [
(
"rbac/permissions/assigned_by_users",
UserAssignedPermissionViewSet,
"permissions-assigned-by-users",
),
(
"rbac/permissions/assigned_by_roles",
RoleAssignedPermissionViewSet,
"permissions-assigned-by-roles",
),
("rbac/permissions/users", UserPermissionViewSet, "permissions-users"),
("rbac/permissions/roles", RolePermissionViewSet, "permissions-roles"),
("rbac/permissions", RBACPermissionViewSet),
("rbac/roles", RoleViewSet, "roles"),

View File

@@ -148,7 +148,7 @@ TENANT_CREATION_FAKES_MIGRATIONS = True
TENANT_BASE_SCHEMA = "template"
PUBLIC_SCHEMA_NAME = CONFIG.get("postgresql.default_schema")
GUARDIAN_MONKEY_PATCH_USER = False
GUARDIAN_ROLE_MODEL = "authentik_rbac.Role"
SPECTACULAR_SETTINGS = {
"TITLE": "authentik",

View File

@@ -87,12 +87,15 @@ class GroupLDAPSynchronizer(BaseLDAPSynchronizer):
# Special check for `users` field, as this is an M2M relation, and cannot be sync'd
if "users" in defaults:
del defaults["users"]
parent = defaults.pop("parent", None)
ak_group, created = Group.update_or_create_attributes(
{
f"attributes__{LDAP_UNIQUENESS}": uniq,
},
defaults,
)
if parent:
ak_group.parents.add(parent)
self._logger.debug("Created group with attributes", **defaults)
if not GroupLDAPSourceConnection.objects.filter(
source=self._source, identifier=uniq

View File

@@ -236,7 +236,7 @@ class LDAPSyncTests(TestCase):
membership_sync.sync_full()
group: Group = Group.objects.filter(name="test-group").first()
self.assertIsNotNone(group)
self.assertEqual(group.parent, parent_group)
self.assertEqual(group.parents.first(), parent_group)
def test_sync_groups_openldap(self):
"""Test group sync"""

View File

@@ -4987,10 +4987,13 @@
"title": "Is superuser",
"description": "Users added to this group will be superusers."
},
"parent": {
"type": "string",
"format": "uuid",
"title": "Parent"
"parents": {
"type": "array",
"items": {
"type": "string",
"format": "uuid"
},
"title": "Parents"
},
"users": {
"type": "array",
@@ -5011,14 +5014,6 @@
"format": "uuid"
},
"title": "Roles"
},
"children": {
"type": "array",
"items": {
"type": "string",
"format": "uuid"
},
"title": "Children"
}
},
"required": []
@@ -5173,6 +5168,14 @@
},
"title": "Groups"
},
"roles": {
"type": "array",
"items": {
"type": "string",
"format": "uuid"
},
"title": "Roles"
},
"email": {
"type": "string",
"format": "email",
@@ -5224,6 +5227,8 @@
"authentik_core.add_applicationentitlement",
"authentik_core.add_authenticatedsession",
"authentik_core.add_group",
"authentik_core.add_groupancestrynode",
"authentik_core.add_groupparentagenode",
"authentik_core.add_groupsourceconnection",
"authentik_core.add_propertymapping",
"authentik_core.add_provider",
@@ -5237,6 +5242,8 @@
"authentik_core.change_applicationentitlement",
"authentik_core.change_authenticatedsession",
"authentik_core.change_group",
"authentik_core.change_groupancestrynode",
"authentik_core.change_groupparentagenode",
"authentik_core.change_groupsourceconnection",
"authentik_core.change_propertymapping",
"authentik_core.change_provider",
@@ -5248,6 +5255,8 @@
"authentik_core.delete_applicationentitlement",
"authentik_core.delete_authenticatedsession",
"authentik_core.delete_group",
"authentik_core.delete_groupancestrynode",
"authentik_core.delete_groupparentagenode",
"authentik_core.delete_groupsourceconnection",
"authentik_core.delete_propertymapping",
"authentik_core.delete_provider",
@@ -5267,6 +5276,8 @@
"authentik_core.view_applicationentitlement",
"authentik_core.view_authenticatedsession",
"authentik_core.view_group",
"authentik_core.view_groupancestrynode",
"authentik_core.view_groupparentagenode",
"authentik_core.view_groupsourceconnection",
"authentik_core.view_propertymapping",
"authentik_core.view_provider",
@@ -10477,14 +10488,6 @@
"minLength": 1,
"title": "Name"
},
"mode": {
"type": "string",
"enum": [
"user",
"role"
],
"title": "Mode"
},
"role": {
"type": "string",
"format": "uuid",
@@ -10552,6 +10555,8 @@
"authentik_core.add_applicationentitlement",
"authentik_core.add_authenticatedsession",
"authentik_core.add_group",
"authentik_core.add_groupancestrynode",
"authentik_core.add_groupparentagenode",
"authentik_core.add_groupsourceconnection",
"authentik_core.add_propertymapping",
"authentik_core.add_provider",
@@ -10565,6 +10570,8 @@
"authentik_core.change_applicationentitlement",
"authentik_core.change_authenticatedsession",
"authentik_core.change_group",
"authentik_core.change_groupancestrynode",
"authentik_core.change_groupparentagenode",
"authentik_core.change_groupsourceconnection",
"authentik_core.change_propertymapping",
"authentik_core.change_provider",
@@ -10576,6 +10583,8 @@
"authentik_core.delete_applicationentitlement",
"authentik_core.delete_authenticatedsession",
"authentik_core.delete_group",
"authentik_core.delete_groupancestrynode",
"authentik_core.delete_groupparentagenode",
"authentik_core.delete_groupsourceconnection",
"authentik_core.delete_propertymapping",
"authentik_core.delete_provider",
@@ -10595,6 +10604,8 @@
"authentik_core.view_applicationentitlement",
"authentik_core.view_authenticatedsession",
"authentik_core.view_group",
"authentik_core.view_groupancestrynode",
"authentik_core.view_groupparentagenode",
"authentik_core.view_groupsourceconnection",
"authentik_core.view_propertymapping",
"authentik_core.view_provider",

View File

@@ -155,7 +155,7 @@ func (ds *DirectSearcher) Search(req *search.Request) (ldap.ServerSearchResult,
if needGroups {
errs.Go(func() error {
gapisp := sentry.StartSpan(errCtx, "authentik.providers.ldap.search.api_group")
searchReq, skip := utils.ParseFilterForGroup(c.CoreApi.CoreGroupsList(gapisp.Context()).IncludeUsers(true).IncludeChildren(true), parsedFilter, false)
searchReq, skip := utils.ParseFilterForGroup(c.CoreApi.CoreGroupsList(gapisp.Context()).IncludeUsers(true).IncludeChildren(true).IncludeParents(true), parsedFilter, false)
if skip {
req.Log().Trace("Skip backend request")
return nil

View File

@@ -57,7 +57,7 @@ func (ms *MemorySearcher) fetch() {
Logger: ms.log,
})
ms.users = users
groups, _ := ak.Paginator(ms.si.GetAPIClient().CoreApi.CoreGroupsList(context.TODO()).IncludeUsers(true).IncludeChildren(true), ak.PaginatorOptions{
groups, _ := ak.Paginator(ms.si.GetAPIClient().CoreApi.CoreGroupsList(context.TODO()).IncludeUsers(true).IncludeChildren(true).IncludeParents(true), ak.PaginatorOptions{
PageSize: 100,
Logger: ms.log,
})
@@ -165,15 +165,8 @@ func (ms *MemorySearcher) Search(req *search.Request) (ldap.ServerSearchResult,
for _, u := range g.UsersObj {
if flag.UserPk == u.Pk {
// TODO: Is there a better way to clone this object?
fg := api.NewGroup(g.Pk, g.NumPk, g.Name, g.ParentName, []api.PartialUser{u}, []api.Role{}, []api.GroupChild{})
fg := api.NewGroup(g.Pk, g.NumPk, g.Name, []api.RelatedGroup{}, []api.PartialUser{u}, []api.Role{}, []string{}, []api.RelatedGroup{})
fg.SetUsers([]int32{flag.UserPk})
if g.Parent.IsSet() {
if p := g.Parent.Get(); p != nil {
fg.SetParent(*p)
} else {
fg.SetParentNil()
}
}
fg.SetAttributes(g.Attributes)
fg.SetIsSuperuser(*g.IsSuperuser)
groups = append(groups, group.FromAPIGroup(*fg, ms.si))

View File

@@ -28,14 +28,12 @@ func (pi *ProviderInstance) MembersForGroup(group api.Group) []string {
}
func (pi *ProviderInstance) MemberOfForGroup(group api.Group) []string {
if group.ParentName.IsSet() {
parent := group.ParentName.Get()
if parent != nil {
return []string{pi.GetGroupDN(*group.ParentName.Get())}
}
groups := make([]string, len(group.ParentsObj))
for i, group := range group.ParentsObj {
fmt.Printf("in range")
groups[i] = pi.GetGroupDN(group.Name)
}
return []string{}
return groups
}
func (pi *ProviderInstance) GetUserDN(user string) string {

View File

@@ -0,0 +1,429 @@
Copyright (c) 2025 Authentik Security Inc.
Copyright (c) 2010-2025 The django-guardian Contributors
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
- Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
- Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
---
The SVG icons in guardian/static/guardian/img are from Font Awesome Free 6.7.2
and subject to CC-BY-4.0.
Copyright (c) 2024 Fonticons, Inc. (https://fontawesome.com)
```
Attribution 4.0 International
=======================================================================
Creative Commons Corporation ("Creative Commons") is not a law firm and
does not provide legal services or legal advice. Distribution of
Creative Commons public licenses does not create a lawyer-client or
other relationship. Creative Commons makes its licenses and related
information available on an "as-is" basis. Creative Commons gives no
warranties regarding its licenses, any material licensed under their
terms and conditions, or any related information. Creative Commons
disclaims all liability for damages resulting from their use to the
fullest extent possible.
Using Creative Commons Public Licenses
Creative Commons public licenses provide a standard set of terms and
conditions that creators and other rights holders may use to share
original works of authorship and other material subject to copyright
and certain other rights specified in the public license below. The
following considerations are for informational purposes only, are not
exhaustive, and do not form part of our licenses.
Considerations for licensors: Our public licenses are
intended for use by those authorized to give the public
permission to use material in ways otherwise restricted by
copyright and certain other rights. Our licenses are
irrevocable. Licensors should read and understand the terms
and conditions of the license they choose before applying it.
Licensors should also secure all rights necessary before
applying our licenses so that the public can reuse the
material as expected. Licensors should clearly mark any
material not subject to the license. This includes other CC-
licensed material, or material used under an exception or
limitation to copyright. More considerations for licensors:
wiki.creativecommons.org/Considerations_for_licensors
Considerations for the public: By using one of our public
licenses, a licensor grants the public permission to use the
licensed material under specified terms and conditions. If
the licensor's permission is not necessary for any reason--for
example, because of any applicable exception or limitation to
copyright--then that use is not regulated by the license. Our
licenses grant only permissions under copyright and certain
other rights that a licensor has authority to grant. Use of
the licensed material may still be restricted for other
reasons, including because others have copyright or other
rights in the material. A licensor may make special requests,
such as asking that all changes be marked or described.
Although not required by our licenses, you are encouraged to
respect those requests where reasonable. More considerations
for the public:
wiki.creativecommons.org/Considerations_for_licensees
=======================================================================
Creative Commons Attribution 4.0 International Public License
By exercising the Licensed Rights (defined below), You accept and agree
to be bound by the terms and conditions of this Creative Commons
Attribution 4.0 International Public License ("Public License"). To the
extent this Public License may be interpreted as a contract, You are
granted the Licensed Rights in consideration of Your acceptance of
these terms and conditions, and the Licensor grants You such rights in
consideration of benefits the Licensor receives from making the
Licensed Material available under these terms and conditions.
Section 1 -- Definitions.
a. Adapted Material means material subject to Copyright and Similar
Rights that is derived from or based upon the Licensed Material
and in which the Licensed Material is translated, altered,
arranged, transformed, or otherwise modified in a manner requiring
permission under the Copyright and Similar Rights held by the
Licensor. For purposes of this Public License, where the Licensed
Material is a musical work, performance, or sound recording,
Adapted Material is always produced where the Licensed Material is
synched in timed relation with a moving image.
b. Adapter's License means the license You apply to Your Copyright
and Similar Rights in Your contributions to Adapted Material in
accordance with the terms and conditions of this Public License.
c. Copyright and Similar Rights means copyright and/or similar rights
closely related to copyright including, without limitation,
performance, broadcast, sound recording, and Sui Generis Database
Rights, without regard to how the rights are labeled or
categorized. For purposes of this Public License, the rights
specified in Section 2(b)(1)-(2) are not Copyright and Similar
Rights.
d. Effective Technological Measures means those measures that, in the
absence of proper authority, may not be circumvented under laws
fulfilling obligations under Article 11 of the WIPO Copyright
Treaty adopted on December 20, 1996, and/or similar international
agreements.
e. Exceptions and Limitations means fair use, fair dealing, and/or
any other exception or limitation to Copyright and Similar Rights
that applies to Your use of the Licensed Material.
f. Licensed Material means the artistic or literary work, database,
or other material to which the Licensor applied this Public
License.
g. Licensed Rights means the rights granted to You subject to the
terms and conditions of this Public License, which are limited to
all Copyright and Similar Rights that apply to Your use of the
Licensed Material and that the Licensor has authority to license.
h. Licensor means the individual(s) or entity(ies) granting rights
under this Public License.
i. Share means to provide material to the public by any means or
process that requires permission under the Licensed Rights, such
as reproduction, public display, public performance, distribution,
dissemination, communication, or importation, and to make material
available to the public including in ways that members of the
public may access the material from a place and at a time
individually chosen by them.
j. Sui Generis Database Rights means rights other than copyright
resulting from Directive 96/9/EC of the European Parliament and of
the Council of 11 March 1996 on the legal protection of databases,
as amended and/or succeeded, as well as other essentially
equivalent rights anywhere in the world.
k. You means the individual or entity exercising the Licensed Rights
under this Public License. Your has a corresponding meaning.
Section 2 -- Scope.
a. License grant.
1. Subject to the terms and conditions of this Public License,
the Licensor hereby grants You a worldwide, royalty-free,
non-sublicensable, non-exclusive, irrevocable license to
exercise the Licensed Rights in the Licensed Material to:
a. reproduce and Share the Licensed Material, in whole or
in part; and
b. produce, reproduce, and Share Adapted Material.
2. Exceptions and Limitations. For the avoidance of doubt, where
Exceptions and Limitations apply to Your use, this Public
License does not apply, and You do not need to comply with
its terms and conditions.
3. Term. The term of this Public License is specified in Section
6(a).
4. Media and formats; technical modifications allowed. The
Licensor authorizes You to exercise the Licensed Rights in
all media and formats whether now known or hereafter created,
and to make technical modifications necessary to do so. The
Licensor waives and/or agrees not to assert any right or
authority to forbid You from making technical modifications
necessary to exercise the Licensed Rights, including
technical modifications necessary to circumvent Effective
Technological Measures. For purposes of this Public License,
simply making modifications authorized by this Section 2(a)
(4) never produces Adapted Material.
5. Downstream recipients.
a. Offer from the Licensor -- Licensed Material. Every
recipient of the Licensed Material automatically
receives an offer from the Licensor to exercise the
Licensed Rights under the terms and conditions of this
Public License.
b. No downstream restrictions. You may not offer or impose
any additional or different terms or conditions on, or
apply any Effective Technological Measures to, the
Licensed Material if doing so restricts exercise of the
Licensed Rights by any recipient of the Licensed
Material.
6. No endorsement. Nothing in this Public License constitutes or
may be construed as permission to assert or imply that You
are, or that Your use of the Licensed Material is, connected
with, or sponsored, endorsed, or granted official status by,
the Licensor or others designated to receive attribution as
provided in Section 3(a)(1)(A)(i).
b. Other rights.
1. Moral rights, such as the right of integrity, are not
licensed under this Public License, nor are publicity,
privacy, and/or other similar personality rights; however, to
the extent possible, the Licensor waives and/or agrees not to
assert any such rights held by the Licensor to the limited
extent necessary to allow You to exercise the Licensed
Rights, but not otherwise.
2. Patent and trademark rights are not licensed under this
Public License.
3. To the extent possible, the Licensor waives any right to
collect royalties from You for the exercise of the Licensed
Rights, whether directly or through a collecting society
under any voluntary or waivable statutory or compulsory
licensing scheme. In all other cases the Licensor expressly
reserves any right to collect such royalties.
Section 3 -- License Conditions.
Your exercise of the Licensed Rights is expressly made subject to the
following conditions.
a. Attribution.
1. If You Share the Licensed Material (including in modified
form), You must:
a. retain the following if it is supplied by the Licensor
with the Licensed Material:
i. identification of the creator(s) of the Licensed
Material and any others designated to receive
attribution, in any reasonable manner requested by
the Licensor (including by pseudonym if
designated);
ii. a copyright notice;
iii. a notice that refers to this Public License;
iv. a notice that refers to the disclaimer of
warranties;
v. a URI or hyperlink to the Licensed Material to the
extent reasonably practicable;
b. indicate if You modified the Licensed Material and
retain an indication of any previous modifications; and
c. indicate the Licensed Material is licensed under this
Public License, and include the text of, or the URI or
hyperlink to, this Public License.
2. You may satisfy the conditions in Section 3(a)(1) in any
reasonable manner based on the medium, means, and context in
which You Share the Licensed Material. For example, it may be
reasonable to satisfy the conditions by providing a URI or
hyperlink to a resource that includes the required
information.
3. If requested by the Licensor, You must remove any of the
information required by Section 3(a)(1)(A) to the extent
reasonably practicable.
4. If You Share Adapted Material You produce, the Adapter's
License You apply must not prevent recipients of the Adapted
Material from complying with this Public License.
Section 4 -- Sui Generis Database Rights.
Where the Licensed Rights include Sui Generis Database Rights that
apply to Your use of the Licensed Material:
a. for the avoidance of doubt, Section 2(a)(1) grants You the right
to extract, reuse, reproduce, and Share all or a substantial
portion of the contents of the database;
b. if You include all or a substantial portion of the database
contents in a database in which You have Sui Generis Database
Rights, then the database in which You have Sui Generis Database
Rights (but not its individual contents) is Adapted Material; and
c. You must comply with the conditions in Section 3(a) if You Share
all or a substantial portion of the contents of the database.
For the avoidance of doubt, this Section 4 supplements and does not
replace Your obligations under this Public License where the Licensed
Rights include other Copyright and Similar Rights.
Section 5 -- Disclaimer of Warranties and Limitation of Liability.
a. UNLESS OTHERWISE SEPARATELY UNDERTAKEN BY THE LICENSOR, TO THE
EXTENT POSSIBLE, THE LICENSOR OFFERS THE LICENSED MATERIAL AS-IS
AND AS-AVAILABLE, AND MAKES NO REPRESENTATIONS OR WARRANTIES OF
ANY KIND CONCERNING THE LICENSED MATERIAL, WHETHER EXPRESS,
IMPLIED, STATUTORY, OR OTHER. THIS INCLUDES, WITHOUT LIMITATION,
WARRANTIES OF TITLE, MERCHANTABILITY, FITNESS FOR A PARTICULAR
PURPOSE, NON-INFRINGEMENT, ABSENCE OF LATENT OR OTHER DEFECTS,
ACCURACY, OR THE PRESENCE OR ABSENCE OF ERRORS, WHETHER OR NOT
KNOWN OR DISCOVERABLE. WHERE DISCLAIMERS OF WARRANTIES ARE NOT
ALLOWED IN FULL OR IN PART, THIS DISCLAIMER MAY NOT APPLY TO YOU.
b. TO THE EXTENT POSSIBLE, IN NO EVENT WILL THE LICENSOR BE LIABLE
TO YOU ON ANY LEGAL THEORY (INCLUDING, WITHOUT LIMITATION,
NEGLIGENCE) OR OTHERWISE FOR ANY DIRECT, SPECIAL, INDIRECT,
INCIDENTAL, CONSEQUENTIAL, PUNITIVE, EXEMPLARY, OR OTHER LOSSES,
COSTS, EXPENSES, OR DAMAGES ARISING OUT OF THIS PUBLIC LICENSE OR
USE OF THE LICENSED MATERIAL, EVEN IF THE LICENSOR HAS BEEN
ADVISED OF THE POSSIBILITY OF SUCH LOSSES, COSTS, EXPENSES, OR
DAMAGES. WHERE A LIMITATION OF LIABILITY IS NOT ALLOWED IN FULL OR
IN PART, THIS LIMITATION MAY NOT APPLY TO YOU.
c. The disclaimer of warranties and limitation of liability provided
above shall be interpreted in a manner that, to the extent
possible, most closely approximates an absolute disclaimer and
waiver of all liability.
Section 6 -- Term and Termination.
a. This Public License applies for the term of the Copyright and
Similar Rights licensed here. However, if You fail to comply with
this Public License, then Your rights under this Public License
terminate automatically.
b. Where Your right to use the Licensed Material has terminated under
Section 6(a), it reinstates:
1. automatically as of the date the violation is cured, provided
it is cured within 30 days of Your discovery of the
violation; or
2. upon express reinstatement by the Licensor.
For the avoidance of doubt, this Section 6(b) does not affect any
right the Licensor may have to seek remedies for Your violations
of this Public License.
c. For the avoidance of doubt, the Licensor may also offer the
Licensed Material under separate terms or conditions or stop
distributing the Licensed Material at any time; however, doing so
will not terminate this Public License.
d. Sections 1, 5, 6, 7, and 8 survive termination of this Public
License.
Section 7 -- Other Terms and Conditions.
a. The Licensor shall not be bound by any additional or different
terms or conditions communicated by You unless expressly agreed.
b. Any arrangements, understandings, or agreements regarding the
Licensed Material not stated herein are separate from and
independent of the terms and conditions of this Public License.
Section 8 -- Interpretation.
a. For the avoidance of doubt, this Public License does not, and
shall not be interpreted to, reduce, limit, restrict, or impose
conditions on any use of the Licensed Material that could lawfully
be made without permission under this Public License.
b. To the extent possible, if any provision of this Public License is
deemed unenforceable, it shall be automatically reformed to the
minimum extent necessary to make it enforceable. If the provision
cannot be reformed, it shall be severed from this Public License
without affecting the enforceability of the remaining terms and
conditions.
c. No term or condition of this Public License will be waived and no
failure to comply consented to unless expressly agreed to by the
Licensor.
d. Nothing in this Public License constitutes or may be interpreted
as a limitation upon, or waiver of, any privileges and immunities
that apply to the Licensor or You, including from the legal
processes of any jurisdiction or authority.
=======================================================================
Creative Commons is not a party to its public
licenses. Notwithstanding, Creative Commons may elect to apply one of
its public licenses to material it publishes and in those instances
will be considered the “Licensor.” The text of the Creative Commons
public licenses is dedicated to the public domain under the CC0 Public
Domain Dedication. Except for the limited purpose of indicating that
material is shared under a Creative Commons public license or as
otherwise permitted by the Creative Commons policies published at
creativecommons.org/policies, Creative Commons does not authorize the
use of the trademark "Creative Commons" or any other trademark or logo
of Creative Commons without its prior written consent including,
without limitation, in connection with any unauthorized modifications
to any of its public licenses or any other arrangements,
understandings, or agreements concerning use of licensed material. For
the avoidance of doubt, this paragraph does not form part of the
public licenses.
Creative Commons may be contacted at creativecommons.org.
```

View File

@@ -0,0 +1 @@
This is a fork of django-guardian.

View File

@@ -0,0 +1,25 @@
"""
Implementation of per object permissions for Django.
"""
def get_version():
"""Return the version string (see pyproject.toml) of the package.
The value will comply with the [python version specifier format dicted by
PEP440](https://packaging.python.org/en/latest/specifications/version-specifiers/#version-specifiers)
Standards for packaging metadata — including version — are defined by PEP 621,
which specifies how to declare version in pyproject.toml.
The earlier PEP 396 suggests (but does not mandate) having a __version__
attribute in __init__.py for the purposes of runtime introspection, but
it leads to confusion in our development process to define it multiple places.
PEP 396 has now been revoked, but it is still useful to be able to inspect
the package version at runtime. This function retains that ability using the
recommended importlib approach.
"""
from importlib.metadata import version
return version("ak-guardian")

View File

@@ -0,0 +1,12 @@
from django.apps import AppConfig
from django.db.models.signals import post_migrate
class GuardianConfig(AppConfig):
name = "guardian"
default_auto_field = "django.db.models.AutoField"
def ready(self):
from .shortcuts import clear_ct_cache
post_migrate.connect(clear_ct_cache)

View File

@@ -0,0 +1,127 @@
from collections.abc import Iterable
from typing import Any
from django.contrib.auth import get_user_model
from django.db import models
from django.db.models import Model
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
def check_object_support(obj: Model) -> bool:
"""Checks if given `obj` is supported
Returns:
`True` if given `obj` is supported
"""
# Backend checks only object permissions (isinstance implies that obj
# is not None)
# Backend checks only permissions for Django models
return isinstance(obj, models.Model)
def check_user_support(user_obj: Any) -> tuple[bool, Any]:
"""Checks if given user is supported.
Checks if the given user is supported. Anonymous users need explicit
activation via ANONYMOUS_USER_NAME
Returns:
A tuple of checkresult and `user_obj` which should be used for permission checks
"""
# This is how we support anonymous users - simply try to retrieve User
# instance and perform checks for that predefined user
if not user_obj.is_authenticated:
# If anonymous user permission is disabled, then they are always unauthorized
if guardian_settings.ANONYMOUS_USER_NAME is None:
return False, user_obj
user_model = get_user_model()
lookup = {user_model.USERNAME_FIELD: guardian_settings.ANONYMOUS_USER_NAME}
user_obj = user_model.objects.get(**lookup)
return True, user_obj
def check_support(user_obj: Any, obj: Model) -> Any:
"""Checks if given user and object are supported.
Combination of `check_object_support` and `check_user_support`
"""
obj_support = obj is None or check_object_support(obj)
user_support, user_obj = check_user_support(user_obj)
return obj_support and user_support, user_obj
class ObjectPermissionBackend:
"""Django backend for checking object-level permissions."""
def authenticate(
self, request: HttpRequest, username: str | None = None, password: str | None = None
) -> Any:
return None
def has_perm(self, user_obj: Any, perm: str, obj: Model | None = None) -> bool:
"""Check if a user has the permission for a given object.
Returns `True` if given `user_obj` has `perm` for `obj`.
If no `obj` is given, global permission is checked.
**Inactive user support**
If `user` is authenticated but inactive at the same time, all checks
always return `False`.
Note:
Remember, that if user is not *active*, all checks would return `False`.
Parameters:
user_obj (User): User instance.
perm (str): Permission string.
obj (Model | None): Django Model instance.
Returns:
`True` if `user_obj` has permission, `False` otherwise.
"""
support, user_obj = check_support(user_obj, obj)
if not support:
return False
if obj is not None and "." in perm:
app_label, _ = perm.split(".", 1)
# TODO (David Graham): Check if obj is None or change the method signature
if app_label != obj._meta.app_label: # type: ignore[union-attr]
# Check the content_type app_label when permission
# and obj app labels don't match.
ctype = get_content_type(obj)
if app_label != ctype.app_label:
raise WrongAppError(
f"Passed perm has app label of '{app_label}' while "
f"given obj has app label '{obj._meta.app_label}' and given obj "
f"content_type has app label '{ctype.app_label}'"
)
check = ObjectPermissionChecker(user_obj)
return check.has_perm(perm, obj)
def get_all_permissions(self, user_obj: Any, obj: Model | None = None) -> Iterable[str]:
"""Returns all permissions for a given object.
Parameters:
user_obj (User): User instance.
obj (Model | None): Django Model instance.
Returns:
a set of permission strings that the given `user_obj` has for `obj`,
or global permissions if `obj` is `None`.
"""
support, user_obj = check_support(user_obj, obj)
if not support:
return set()
check = ObjectPermissionChecker(user_obj)
return check.get_perms(obj)

View File

@@ -0,0 +1,20 @@
from django.conf import settings
from django.core.checks import Tags, Warning, register
@register(Tags.compatibility)
def check_settings(app_configs, **kwargs):
"""Check that settings are implemented properly
:param app_configs: a list of apps to be checks or None for all
:param kwargs: keyword arguments
:return: a list of errors
"""
checks = []
if "guardian.backends.ObjectPermissionBackend" not in settings.AUTHENTICATION_BACKENDS:
msg = (
"Guardian authentication backend is not hooked. You can add this in settings as eg: "
"`AUTHENTICATION_BACKENDS = ('django.contrib.auth.backends.ModelBackend', "
"'guardian.backends.ObjectPermissionBackend')`."
)
checks.append(Warning(msg, id="guardian.W001"))
return checks

View File

@@ -0,0 +1,31 @@
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
ANONYMOUS_USER_NAME = getattr(settings, "GUARDIAN_ANONYMOUS_USER_NAME", "AnonymousUser")
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")
role_model_label = getattr(settings, "GUARDIAN_ROLE_MODEL", None)
if role_model_label is None:
raise ImproperlyConfigured("ak-guardian requires settings.GUARDIAN_ROLE_MODEL")

View File

@@ -0,0 +1,127 @@
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
def remove_app_label(perm: str) -> str:
if "." in perm:
_, perm = perm.split(".", 1)
return perm
class ObjectPermissionChecker:
"""Generic object permissions checker class being the heart of `ak-guardian`.
Note:
Once checked for a single object, permissions are stored, and we don't hit
the database again if another check is called for this object. This is great
for templates, views or other request-based checks (assuming we don't
have hundreds of permissions on a single object as we fetch all
permissions for checked object).
if we call `has_perm` for perm1/object1, then we
change permission state and call `has_perm` again for same
perm1/object1 on the same instance of ObjectPermissionChecker we won't see a
difference as permissions are already fetched and stored within the cache
dictionary.
"""
def __init__(self, identity: Model | None = None) -> None:
"""Constructor for ObjectPermissionChecker.
Parameters:
identity (User | AnonymousUser | Group | Role): The identity to check permissions for.
"""
self.user, self.group, self.role = get_identity(identity) # type: ignore[arg-type] # None is not allowed
self._obj_perms_cache: dict = {}
def has_perm(self, perm: str, obj: Model | None = None) -> bool:
"""Checks if user/group/role has the specified permission for the given object.
Parameters:
perm (str): permission as string, may or may not contain app_label
prefix (if not prefixed, we grab app_label from `obj`)
obj (Model | None): Django's `Model` instance or `None` if querying a global permission.
*Default* is `None`.
Returns:
True if user/group/role has the permission, False otherwise
"""
if self.user and not self.user.is_active:
return False
elif self.user and self.user.is_superuser:
return True
perms = self.get_perms(obj)
if "." not in perm:
perms = {remove_app_label(perm) for perm in perms}
return perm in perms
def role_filter(self, related_name: str) -> dict:
if self.user:
return {f"{related_name}__role__in": self.user.all_roles()}
elif self.group:
return {f"{related_name}__role__in": self.group.all_roles()}
elif self.role:
return {f"{related_name}__role": self.role}
return {}
def object_filter(self, obj: Model) -> dict:
from guardian.models import RoleObjectPermission
related_name = RoleObjectPermission.permission.field.related_query_name()
filter = {
f"{related_name}__content_type": get_content_type(obj),
f"{related_name}__object_pk": obj.pk,
}
filter.update(self.role_filter(related_name))
return filter
def model_filter(self) -> dict:
from guardian.models import RoleModelPermission
related_name = RoleModelPermission.permission.field.related_query_name()
filter = self.role_filter(related_name)
return filter
def get_perms(self, obj: Model | None = None) -> set[str]:
"""Get a list of permissions for the given object.
Parameters:
obj (Model | None): Django's `Model` instance or `None` if querying a global permission.
*Default* is `None`.
Returns:
set of codenames for all permissions for given `obj`.
"""
if self.user and not self.user.is_active:
return set()
key = self.get_local_cache_key(obj)
if key not in self._obj_perms_cache:
if self.user and self.user.is_superuser:
perms = Permission.objects.all()
if obj:
perms = perms.filter(get_content_type(type(obj)))
else:
filter = Q(**self.model_filter())
if obj:
filter |= Q(**self.object_filter(obj))
perms = Permission.objects.filter(filter)
perms_list = list(set(perms.values_list("content_type__app_label", "codename")))
self._obj_perms_cache[key] = {f"{ct}.{name}" for ct, name in perms_list}
return self._obj_perms_cache[key]
def get_local_cache_key(self, obj: Model | None) -> tuple:
"""Returns cache key for `_obj_perms_cache` dict."""
if not obj:
return ("", "")
ctype = get_content_type(obj)
return ctype.id, force_str(obj.pk)

View File

@@ -0,0 +1,28 @@
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

@@ -0,0 +1,40 @@
"""
Exceptions used by ak-guardian. All internal and guardian-specific errors
should extend GuardianError class.
"""
class GuardianError(Exception):
"""Base class for all guardian-specific exceptions."""
pass
class InvalidIdentity(GuardianError):
"""Raised when an object is neither User nor Group nor Role."""
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."""
pass
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

@@ -0,0 +1,56 @@
from django.contrib.auth import get_user_model
from django.db import DatabaseError, router
from django.db.models import signals
from django.utils.module_loading import import_string
from guardian.conf import settings as guardian_settings
def get_init_anonymous_user(User):
"""
Returns User model instance that would be referenced by guardian when
permissions are checked against users that haven't signed into the system.
:param User: User model - result of ``django.contrib.auth.get_user_model``.
"""
kwargs = {User.USERNAME_FIELD: guardian_settings.ANONYMOUS_USER_NAME}
user = User(**kwargs)
user.set_unusable_password()
return user
def create_anonymous_user(sender, **kwargs):
"""
Creates anonymous User instance with id and username from settings.
"""
User = get_user_model()
if not router.allow_migrate_model(kwargs["using"], User):
return
try:
lookup = {User.USERNAME_FIELD: guardian_settings.ANONYMOUS_USER_NAME}
# fixing #770
User.objects.using(kwargs["using"]).filter(**lookup).only(User.USERNAME_FIELD).get()
except (User.DoesNotExist, DatabaseError):
# Handle both cases: user doesn't exist AND table doesn't exist (rollback scenario)
try:
retrieve_anonymous_function = import_string(guardian_settings.GET_INIT_ANONYMOUS_USER)
user = retrieve_anonymous_function(User)
user.save(using=kwargs["using"])
except DatabaseError:
# If we still get a DatabaseError when trying to save,
# it means the table doesn't exist (rollback scenario)
# In this case, we should silently return as the migration
# will handle user creation when it's run again
return
# Only create an anonymous user if support is enabled.
if guardian_settings.ANONYMOUS_USER_NAME is not None:
from django.apps import apps
guardian_app = apps.get_app_config("guardian")
signals.post_migrate.connect(
create_anonymous_user,
sender=guardian_app,
dispatch_uid="guardian.management.create_anonymous_user",
)

View File

@@ -0,0 +1,65 @@
from django.core.management.base import BaseCommand
from guardian.utils import clean_orphan_obj_perms
class Command(BaseCommand):
"""A wrapper around `guardian.utils.clean_orphan_obj_perms`.
Seeks and removes all object permissions entries pointing at non-existing targets.
Returns the number of objects removed.
Example:
```shell
$ python manage.py clean_orphan_obj_perms
Removed 11 object permission entries with no targets
$ python manage.py clean_orphan_obj_perms --batch-size 100 --max-batches 5
Removed 500 object permission entries with no targets
```
"""
help = "Removes object permissions with not existing targets"
def add_arguments(self, parser):
parser.add_argument(
"--batch-size",
type=int,
help="Number of objects to process per batch. If not specified, batch size is infinite",
)
parser.add_argument(
"--max-batches",
type=int,
help="Maximum number of batches to process. Use with --batch-size.",
)
parser.add_argument(
"--max-duration-secs",
type=int,
help="Maximum duration in seconds for the cleanup operation.",
)
parser.add_argument(
"--skip-batches",
type=int,
default=0,
help="Number of batches to skip before starting cleanup. Use with --batch-size.",
)
def handle(self, **options):
kwargs = {}
if options["batch_size"] is not None:
kwargs["batch_size"] = options["batch_size"]
if options["max_batches"] is not None:
kwargs["max_batches"] = options["max_batches"]
if options["max_duration_secs"] is not None:
kwargs["max_duration_secs"] = options["max_duration_secs"]
if options["skip_batches"] > 0:
kwargs["skip_batches"] = options["skip_batches"]
removed = clean_orphan_obj_perms(**kwargs)
if options["verbosity"] > 0:
self.stdout.write(f"Removed {removed} object permission entries with no targets")

View File

@@ -0,0 +1,108 @@
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,61 @@
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("contenttypes", "0001_initial"),
("auth", "0001_initial"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="GroupObjectPermission",
fields=[
(
"id",
models.AutoField(
primary_key=True, serialize=False, auto_created=True, verbose_name="ID"
),
),
("object_pk", models.CharField(max_length=255, verbose_name="object ID")),
(
"content_type",
models.ForeignKey(to="contenttypes.ContentType", on_delete=models.CASCADE),
),
("group", models.ForeignKey(to="auth.Group", on_delete=models.CASCADE)),
("permission", models.ForeignKey(to="auth.Permission", on_delete=models.CASCADE)),
],
options={},
bases=(models.Model,),
),
migrations.CreateModel(
name="UserObjectPermission",
fields=[
(
"id",
models.AutoField(
primary_key=True, serialize=False, auto_created=True, verbose_name="ID"
),
),
("object_pk", models.CharField(max_length=255, verbose_name="object ID")),
(
"content_type",
models.ForeignKey(to="contenttypes.ContentType", on_delete=models.CASCADE),
),
("permission", models.ForeignKey(to="auth.Permission", on_delete=models.CASCADE)),
("user", models.ForeignKey(to=settings.AUTH_USER_MODEL, on_delete=models.CASCADE)),
],
options={},
bases=(models.Model,),
),
migrations.AlterUniqueTogether(
name="userobjectpermission",
unique_together={("user", "permission", "object_pk")},
),
migrations.AlterUniqueTogether(
name="groupobjectpermission",
unique_together={("group", "permission", "object_pk")},
),
]

View File

@@ -0,0 +1,24 @@
# Generated by Django 2.2.2 on 2019-06-29 07:30
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("guardian", "0001_initial"),
]
operations = [
migrations.AddIndex(
model_name="groupobjectpermission",
index=models.Index(
fields=["content_type", "object_pk"], name="guardian_gr_content_ae6aec_idx"
),
),
migrations.AddIndex(
model_name="userobjectpermission",
index=models.Index(
fields=["content_type", "object_pk"], name="guardian_us_content_179ed2_idx"
),
),
]

View File

@@ -0,0 +1,50 @@
# Generated by Django 5.2.5 on 2025-08-28 23:02
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("auth", "0012_alter_user_first_name_max_length"),
("contenttypes", "0002_remove_content_type_name"),
("guardian", "0002_generic_permissions_index"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.RemoveIndex(
model_name="groupobjectpermission",
name="guardian_gr_content_ae6aec_idx",
),
migrations.RemoveIndex(
model_name="userobjectpermission",
name="guardian_us_content_179ed2_idx",
),
migrations.AddIndex(
model_name="groupobjectpermission",
index=models.Index(
fields=["permission", "group", "content_type", "object_pk"],
name="guardian_gr_permiss_83545c_idx",
),
),
migrations.AddIndex(
model_name="groupobjectpermission",
index=models.Index(
fields=["group", "content_type", "object_pk"], name="guardian_gr_group_i_9e7d12_idx"
),
),
migrations.AddIndex(
model_name="userobjectpermission",
index=models.Index(
fields=["permission", "user", "content_type", "object_pk"],
name="guardian_us_permiss_e5749c_idx",
),
),
migrations.AddIndex(
model_name="userobjectpermission",
index=models.Index(
fields=["user", "content_type", "object_pk"], name="guardian_us_user_id_8eae14_idx"
),
),
]

View File

@@ -0,0 +1,99 @@
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("auth", "0012_alter_user_first_name_max_length"),
("contenttypes", "0002_remove_content_type_name"),
("guardian", "0003_remove_groupobjectpermission_guardian_gr_content_ae6aec_idx_and_more"),
]
operations = [
migrations.CreateModel(
name="RoleModelPermission",
fields=[
(
"id",
models.AutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
),
),
(
"content_type",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="contenttypes.contenttype"
),
),
(
"permission",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="auth.permission"
),
),
(
"role",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="authentik_rbac.role"
),
),
],
options={
"indexes": [
models.Index(
fields=["permission", "role", "content_type"],
name="guardian_ro_permiss_eb3837_idx",
),
models.Index(
fields=["role", "content_type"], name="guardian_ro_role_id_268ee1_idx"
),
],
"unique_together": {("role", "permission")},
},
),
migrations.CreateModel(
name="RoleObjectPermission",
fields=[
(
"id",
models.AutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
),
),
("object_pk", models.CharField(max_length=255, verbose_name="object ID")),
(
"content_type",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="contenttypes.contenttype"
),
),
(
"permission",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="auth.permission"
),
),
(
"role",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="authentik_rbac.role"
),
),
],
options={
"abstract": False,
"indexes": [
models.Index(
fields=["permission", "role", "content_type", "object_pk"],
name="guardian_ro_permiss_731f53_idx",
),
models.Index(
fields=["role", "content_type", "object_pk"],
name="guardian_ro_role_id_82d58d_idx",
),
],
"unique_together": {("role", "permission", "object_pk")},
},
),
]

View File

@@ -0,0 +1,152 @@
from django.contrib.auth.models import Group, Permission
from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError
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,
)
class BaseObjectPermission(models.Model):
permission = models.ForeignKey(Permission, on_delete=models.CASCADE)
class Meta:
abstract = True
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),
)
def save(self, *args, **kwargs) -> None:
content_type = get_content_type(self.content_object)
if content_type != self.permission.content_type:
raise ValidationError(
"Cannot persist permission not designed for this class (permission's type is "
f"{self.permission.content_type} and object's type is {content_type})"
)
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)
role = models.ForeignKey(guardian_settings.role_model_label, on_delete=models.CASCADE)
class Meta:
unique_together = ["role", "permission"]
indexes = [
models.Index(fields=["permission", "role", "content_type"]),
models.Index(fields=["role", "content_type"]),
]
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

@@ -0,0 +1,336 @@
"""Convenient shortcuts to manage or check object permissions."""
from functools import lru_cache, partial
from typing import Any, TypeVar
from django.contrib.auth.models import Permission
from django.contrib.contenttypes.models import ContentType
from django.db import connection
from django.db.models import (
AutoField,
BigIntegerField,
CharField,
Count,
ForeignKey,
IntegerField,
Model,
PositiveIntegerField,
PositiveSmallIntegerField,
QuerySet,
SmallIntegerField,
UUIDField,
)
from django.db.models.expressions import Value
from django.db.models.functions import Cast, Replace
from guardian.core import ObjectPermissionChecker
from guardian.ctypes import get_content_type
from guardian.exceptions import (
GuardianError,
InvalidIdentity,
MixedContentTypeError,
)
from guardian.utils import (
get_anonymous_user,
get_identity,
get_role_model_perms_model,
get_role_obj_perms_model,
)
@lru_cache(None)
def _get_ct_cached(app_label: str, codename: str) -> ContentType:
"""Caches `ContentType` instances like its `QuerySet` does."""
return ContentType.objects.get(app_label=app_label, permission__codename=codename)
# kwargs are required to be connected to a django signal
def clear_ct_cache(**kwargs) -> None:
"""Helper to clear cache of `_get_ct_cached`"""
if hasattr(_get_ct_cached, "cache_clear"):
_get_ct_cached.cache_clear()
def assign_perm(
perm: str | Permission,
role: Any,
obj: Model | None = None,
) -> str | Permission | None:
"""Assigns permission to role and object pair.
Parameters:
perm (str | Permission): permission to assign for the given `obj`,
in format: `app_label.codename` or `codename` or `Permission` instance.
If `obj` is not given, must be in format `app_label.codename` or `Permission` instance.
role (Role):
The role to add the parmission to.
Passing any other object would raise `guardian.exceptions.InvalidIdentity`
obj (Model | None): Django's `Model` instance or `None` if assigning a global permission.
*Default* is `None`.
"""
role_model = get_role_obj_perms_model().role.field.related_model
if not isinstance(role, role_model):
raise InvalidIdentity("Can only assign_perm to a Role.")
role = get_identity(role)[2]
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
kwargs = {
"content_type": permission.content_type,
"permission": permission,
"role": role,
}
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)
def remove_perm(
perm: str | Permission,
role: Any,
obj: Model | QuerySet | None = None,
) -> None:
"""Removes permission from role and object pair.
Parameters:
perm (str): Permission for `obj`, in format `app_label.codename` or `codename`.
If `obj` is not given, must be in format `app_label.codename`.
role (Role): The role to remove the permission from.
Passing any other object would raise `guardian.exceptions.InvalidIdentity`
obj (Model): Django's `Model` instance or `None` if removing a global permission.
*Default* is `None`.
"""
role_model = get_role_obj_perms_model().role.field.related_model
if not isinstance(role, role_model):
raise InvalidIdentity("Can only assign_perm to a Role.")
role = get_identity(role)[2]
if not role:
return None
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)
def get_perms(identity: Any, obj: Model | None = None) -> set[str]:
"""Gets the permissions for given user/group/role and object pair,
Returns:
List of permissions for the given user/group/role and object pair.
"""
check = ObjectPermissionChecker(identity)
return check.get_perms(obj)
T = TypeVar("T", bound=Model)
def get_objects_for_user( # noqa: PLR0912 PLR0915
user: Any,
perms: str | list[str],
queryset: QuerySet | None = None,
) -> QuerySet:
"""Get objects that a user has *all* the supplied permissions for.
Parameters:
user (User | AnonymousUser): user to check for permissions.
perms (str | list[str]): permission(s) to be checked.
These should be full permission names rather than only codenames
(i.e. `auth.change_user`).
If more than one permission is present within sequence, their content type **must** be
the same or `MixedContentTypeError` exception would be raised.
queryset (QuerySet): a queryset from which to filter objects.
If not present, the base queryset will just be all objects for the given `perms`.
Raises:
MixedContentTypeError: when computed content type for `perms` clashes.
Example:
```shell
>>> from django.contrib.auth.models import User
>>> from guardian.shortcuts import get_objects_for_user
>>> joe = User.objects.get(username='joe')
>>> get_objects_for_user(joe, 'auth.change_group')
[]
>>> from guardian.shortcuts import assign_perm
>>> group = Group.objects.create('some group')
>>> assign_perm('auth.change_group', joe, group)
>>> get_objects_for_user(joe, 'auth.change_group')
[<Group some group>]
# The permission string can also be an iterable. Continuing with the previous example:
>>> get_objects_for_user(joe, ['auth.change_group', 'auth.delete_group'])
[]
>>> get_objects_for_user(joe, ['auth.change_group', 'auth.delete_group'], any_perm=True)
[<Group some group>]
>>> assign_perm('auth.delete_group', joe, group)
>>> get_objects_for_user(joe, ['auth.change_group', 'auth.delete_group'])
[<Group some group>]
"""
if isinstance(perms, str):
perms = [perms]
ctype = None
app_label = None
codenames = set()
pk_field = "object_pk"
# Compute codenames, app_label, ctype
for perm in perms:
if "." not in perm:
raise GuardianError(f"Cannot determine app label and content type from {perm}")
new_app_label, new_codename = perm.split(".", 1)
if not new_app_label or not new_codename:
raise GuardianError(f"Cannot determine app label and content type from {perm}")
if app_label is not None and app_label != new_app_label:
raise MixedContentTypeError(
f"Given perms must have same app label ({app_label} != {new_app_label})"
)
new_ctype = _get_ct_cached(new_app_label, new_codename)
if ctype is not None and ctype != new_ctype:
raise MixedContentTypeError(
f"ContentType was once computed to be {ctype} and another one {new_ctype}"
)
ctype = new_ctype
app_label = new_app_label
codenames.add(new_codename)
if queryset is None:
queryset = ctype.model_class()._default_manager.all()
elif ctype != get_content_type(queryset.model):
raise MixedContentTypeError("Content type for given perms and queryset differs")
# Superuser has access to all objects
if user.is_superuser:
return queryset
# The anonymous user can have permissions
if user.is_anonymous:
user = get_anonymous_user()
# If the user has a model-level permission, we don't need to filter on it
model_perms = {code for code in codenames if user.has_perm(ctype.app_label + "." + code)}
for code in model_perms:
codenames.discard(code)
# We may be done
if len(codenames) == 0:
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)
perms_queryset = (
role_model.objects.filter(role__in=user.all_roles())
.filter(permission__content_type=ctype)
.filter(permission__codename__in=codenames)
)
if len(codenames) > 1:
perms_queryset = (
perms_queryset.values(pk_field)
.annotate(object_pk_count=Count(pk_field))
.filter(object_pk_count__gte=len(codenames))
)
# object_pk is a varchar, while the queryset's pk is probably an integer or a uuid, so we cast
handle_pk_field = _handle_pk_field(queryset)
if handle_pk_field is not None:
perms_queryset = perms_queryset.annotate(obj_pk=handle_pk_field(expression=pk_field))
pk_field = "obj_pk"
return queryset.filter(pk__in=perms_queryset.values_list(pk_field, flat=True))
def _handle_pk_field(queryset):
pk = queryset.model._meta.pk
if isinstance(pk, ForeignKey):
return _handle_pk_field(pk.target_field)
if isinstance( # noqa: UP038
pk,
(
IntegerField,
AutoField,
BigIntegerField,
PositiveIntegerField,
PositiveSmallIntegerField,
SmallIntegerField,
),
):
return partial(Cast, output_field=BigIntegerField())
if isinstance(pk, UUIDField):
if connection.features.has_native_uuid_field:
return partial(Cast, output_field=UUIDField())
return partial(
Replace,
text=Value("-"),
replacement=Value(""),
output_field=CharField(),
)
return None

View File

@@ -0,0 +1,348 @@
"""
ak-guardian helper functions.
Functions defined within this module are a part of ak-guardian's internal functionality
and be considered unstable; their APIs may change in any future releases.
"""
import gc
import logging
import time
from math import ceil
from typing import Any
from django.apps import apps as django_apps
from django.contrib.auth import get_user_model
from django.contrib.auth.models import AnonymousUser
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
from guardian.exceptions import InvalidIdentity
logger = logging.getLogger(__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}"
# Try to get from cache first
user = cache.get(cache_key)
if user is not None:
return user
# If not in cache, get from database and cache it
user_model = get_user_model()
lookup = {user_model.USERNAME_FIELD: guardian_settings.ANONYMOUS_USER_NAME} # type: ignore[attr-defined]
user = user_model.objects.get(**lookup)
# Cache with TTL from settings
# -1 means cache indefinitely (None), positive number is TTL in seconds
ttl = (
None
if guardian_settings.ANONYMOUS_USER_CACHE_TTL == -1
else guardian_settings.ANONYMOUS_USER_CACHE_TTL
)
cache.set(cache_key, user, ttl)
return user
def _get_anonymous_user_uncached() -> Any:
"""Internal uncached version of get_anonymous_user."""
user_model = get_user_model()
lookup = {user_model.USERNAME_FIELD: guardian_settings.ANONYMOUS_USER_NAME} # type: ignore[attr-defined]
return user_model.objects.get(**lookup)
def get_anonymous_user() -> Any:
"""Get the ak-guardian equivalent of the anonymous user.
It returns a `User` model instance (not `AnonymousUser`) depending on
`ANONYMOUS_USER_NAME` configuration.
This function can be cached to avoid repetitive database queries based on the
`GUARDIAN_ANONYMOUS_USER_CACHE_TTL` setting:
- 0 (default): No caching, each call performs a fresh database query
- Positive number: Cache for that many seconds
- -1: Cache indefinitely (not recommended)
See Also:
See the configuration docs that explain that the Guardian anonymous user is
not equivalent to Django's AnonymousUser.
- [Guardian Configuration](https://django-guardian.readthedocs.io/en/stable/configuration.html)
- [ANONYMOUS_USER_NAME configuration](https://django-guardian.readthedocs.io/en/stable/configuration.html#anonymous-user-nam)
- [ANONYMOUS_USER_CACHE_TTL configuration](https://django-guardian.readthedocs.io/en/stable/configuration.html#anonymous-user-cache-ttl)
"""
if (
guardian_settings.ANONYMOUS_USER_CACHE_TTL > 0
or guardian_settings.ANONYMOUS_USER_CACHE_TTL == -1
):
return _get_anonymous_user_cached()
else:
return _get_anonymous_user_uncached()
def get_identity(identity: Model) -> tuple[Any | None, Any | None, Any | None]:
"""Get a tuple with the identity of the given input.
Returns:
(user_obj, None, None) or
(None, group_obj, None) or
(None, None, role_obj)
Parameters:
identity: User | AnonymousUser | Group | Role
Raises:
InvalidIdentity: If the function cannot return proper identity instance
"""
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
# get identity from queryset model type
if isinstance(identity, QuerySet):
identity_model_type = identity.model
if identity_model_type == get_user_model():
return identity, None, None
elif identity_model_type == group_model:
return None, identity, None
elif identity_model_type == role_model:
return None, None, identity
# get identity from the first element in the list
if isinstance(identity, list) and isinstance(identity[0], get_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()):
return identity, None, None
if isinstance(identity, group_model):
return None, identity, None
if isinstance(identity, role_model):
return None, None, identity
raise InvalidIdentity(
f"User/AnonymousUser or Group or Role instance is required (got {identity})"
)
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,
max_duration_secs: int | None = None,
skip_batches: int = 0,
) -> int:
"""
Removes orphan object permissions using queryset slice-based batching,
batch skipping, batch limit, and time-based interruption.
"""
RoleObjectPermission = get_role_obj_perms_model()
deleted = 0
scanned = 0
processed_batches = 0
batch_count = 0
start_time = time.monotonic()
if batch_size is None:
all_objs = list(RoleObjectPermission.objects.order_by("pk"))
for obj in all_objs:
if max_duration_secs is not None and (
time.monotonic() - start_time >= max_duration_secs
):
logger.info(f"Time limit of {max_duration_secs}s reached.")
break
scanned += 1
if obj.content_object is None:
logger.debug("Removing %s (pk=%d)", obj, obj.pk)
obj.delete()
deleted += 1
processed_batches = 1
else:
total_role = RoleObjectPermission.objects.count()
total_batches_possible = ceil(total_role / batch_size)
remaining_batches = total_batches_possible - skip_batches
if max_batches is not None:
remaining_batches = min(remaining_batches, max_batches)
logger.info(
f"Starting orphan object permissions cleanup with batch_size={batch_size}, "
f"max_batches={max_batches}, max_duration_secs={max_duration_secs}, "
f"skip_batches={skip_batches}"
)
roles_processed = 0
# Skip batches if needed
role_skip_records = min(skip_batches * batch_size, total_role)
roles_remaining = total_role - role_skip_records
while roles_remaining > 0 and remaining_batches > 0:
if max_duration_secs is not None and (
time.monotonic() - start_time >= max_duration_secs
):
logger.info(f"Time limit of {max_duration_secs}s reached.")
break
gc.collect()
current_batch_size = min(batch_size, roles_remaining)
batch = list(
RoleObjectPermission.objects.order_by("pk")[
role_skip_records
+ roles_processed : role_skip_records
+ roles_processed
+ current_batch_size
]
)
if not batch:
break
scanned += len(batch)
roles_processed += len(batch)
roles_remaining -= len(batch)
orphan_pks = [obj.pk for obj in batch if obj.content_object is None]
if orphan_pks:
logger.info(
f"!!! Found {len(orphan_pks)} orphan role permissions in batch "
f"{processed_batches + 1}. !!!"
)
RoleObjectPermission.objects.filter(pk__in=orphan_pks).delete()
deleted += len(orphan_pks)
processed_batches += 1
batch_count += 1
remaining_batches -= 1
logger.info(f"Processed role batch {processed_batches}, scanned {scanned} objects.")
logger.info(
f"Finished orphan object permissions cleanup. "
f"Scanned: {scanned} | Removed: {deleted} | "
f"Batches processed: {processed_batches}"
)
if batch_size:
suggestion = (
f"To resume cleanup, call:\n"
f"clean_orphan_obj_perms(batch_size={batch_size}, "
f"skip_batches={skip_batches + processed_batches}, "
)
if max_batches is not None:
suggestion += f"max_batches={max_batches - batch_count}, "
if max_duration_secs is not None:
suggestion += f"max_duration_secs={max_duration_secs}, "
suggestion = suggestion.rstrip(", ") + ")"
logger.info(suggestion)
return deleted

View File

@@ -0,0 +1,60 @@
[project]
name = "ak-guardian"
version = "3.2.0"
description = "Model and object permissions for Django"
requires-python = ">=3.9,<3.14"
readme = "README.md"
license = { text = "BSD-2-Clause" }
authors = [{ name = "Authentik Security Inc.", email = "hello@goauthentik.io" }]
keywords = ["django", "permissions", "authorization", "object", "row", "level"]
classifiers = [
"Development Status :: 3 - Alpha",
"Intended Audience :: Developers",
'Environment :: Web Environment',
"Topic :: Software Development :: Libraries :: Python Modules",
"License :: OSI Approved :: BSD License",
'Framework :: Django',
'Framework :: Django :: 3.2',
'Framework :: Django :: 4.1',
'Framework :: Django :: 4.2',
'Framework :: Django :: 5.0',
'Framework :: Django :: 5.1',
'Framework :: Django :: 5.2',
'Programming Language :: Python',
"Programming Language :: Python :: 3 :: Only",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Operating System :: OS Independent",
'Topic :: Security',
]
dependencies = [
"django>=5.2,<6.0",
"typing_extensions>=4.12.0; python_version<'3.13'",
]
[project.urls]
Homepage = "https://github.com/goauthentik/authentik/tree/main/packages/ak-guardian"
Documentation = "https://github.com/goauthentik/authentik/tree/main/packages/ak-guardian"
Repository = "https://github.com/goauthentik/authentik/tree/main/packages/ak-guardian"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = [
"guardian",
"guardian.conf",
"guardian.management",
"guardian.management.commands",
"guardian.migrations",
]
[tool.setuptools.packages]
find = {}

View File

@@ -5,6 +5,7 @@ description = ""
authors = [{ name = "authentik Team", email = "hello@goauthentik.io" }]
requires-python = "==3.13.*"
dependencies = [
"ak-guardian==3.2.0",
"argon2-cffi==25.1.0",
"channels==4.3.1",
"cryptography==45.0.5",
@@ -19,7 +20,6 @@ dependencies = [
"django-postgres-cache",
"django-postgres-extra==2.0.9",
"django-filter==25.1",
"django-guardian==3.0.3",
"django-model-utils==5.0.0",
"django-pglock==1.7.2",
"django-pgtrigger==4.15.2",
@@ -27,7 +27,6 @@ dependencies = [
"django-storages[s3]==1.14.6",
"django-tenants==3.9.0",
"djangoql==0.18.1",
"djangorestframework-guardian==0.4.0",
"djangorestframework==3.16.1",
"docker==7.1.0",
"drf-orjson-renderer==1.7.3",
@@ -121,6 +120,7 @@ no-binary-package = [
]
[tool.uv.sources]
ak-guardian = { workspace = true }
django-channels-postgres = { workspace = true }
django-dramatiq-postgres = { workspace = true }
django-postgres-cache = { workspace = true }
@@ -128,6 +128,7 @@ opencontainers = { git = "https://github.com/vsoch/oci-python", rev = "ceb4fcc09
[tool.uv.workspace]
members = [
"packages/ak-guardian",
"packages/django-channels-postgres",
"packages/django-dramatiq-postgres",
"packages/django-postgres-cache",
@@ -279,6 +280,7 @@ module = [
"authentik.tasks.*",
"authentik.tasks.schedules.*",
"authentik.tenants.*",
"guardian.*",
"lifecycle.*",
"tests.e2e.*",
"tests.integration.*",

File diff suppressed because it is too large Load Diff

View File

@@ -2,7 +2,6 @@
from dataclasses import asdict
from guardian.shortcuts import assign_perm
from ldap3 import ALL, ALL_ATTRIBUTES, ALL_OPERATIONAL_ATTRIBUTES, SUBTREE, Connection, Server
from ldap3.core.exceptions import LDAPInvalidCredentialsResult
@@ -44,7 +43,7 @@ class TestProviderLDAP(SeleniumTestCase):
authorization_flow=Flow.objects.get(slug="default-authentication-flow"),
search_mode=APIAccessMode.CACHED,
)
assign_perm("search_full_directory", self.user, ldap)
self.user.assign_perms_to_managed_role("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(

47
uv.lock generated
View File

@@ -4,6 +4,7 @@ requires-python = "==3.13.*"
[manifest]
members = [
"ak-guardian",
"authentik",
"django-channels-postgres",
"django-dramatiq-postgres",
@@ -77,6 +78,20 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" },
]
[[package]]
name = "ak-guardian"
version = "3.2.0"
source = { editable = "packages/ak-guardian" }
dependencies = [
{ name = "django" },
]
[package.metadata]
requires-dist = [
{ name = "django", specifier = ">=5.2,<6.0" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'", specifier = ">=4.12.0" },
]
[[package]]
name = "annotated-types"
version = "0.7.0"
@@ -172,6 +187,7 @@ name = "authentik"
version = "2025.12.0rc1"
source = { editable = "." }
dependencies = [
{ name = "ak-guardian" },
{ name = "argon2-cffi" },
{ name = "channels" },
{ name = "cryptography" },
@@ -184,7 +200,6 @@ dependencies = [
{ name = "django-cte" },
{ name = "django-dramatiq-postgres" },
{ name = "django-filter" },
{ name = "django-guardian" },
{ name = "django-model-utils" },
{ name = "django-pglock" },
{ name = "django-pgtrigger" },
@@ -195,7 +210,6 @@ dependencies = [
{ name = "django-tenants" },
{ name = "djangoql" },
{ name = "djangorestframework" },
{ name = "djangorestframework-guardian" },
{ name = "docker" },
{ name = "drf-orjson-renderer" },
{ name = "drf-spectacular" },
@@ -277,6 +291,7 @@ dev = [
[package.metadata]
requires-dist = [
{ name = "ak-guardian", editable = "packages/ak-guardian" },
{ name = "argon2-cffi", specifier = "==25.1.0" },
{ name = "channels", specifier = "==4.3.1" },
{ name = "cryptography", specifier = "==45.0.5" },
@@ -289,7 +304,6 @@ requires-dist = [
{ name = "django-cte", specifier = "==2.0.0" },
{ name = "django-dramatiq-postgres", editable = "packages/django-dramatiq-postgres" },
{ name = "django-filter", specifier = "==25.1" },
{ name = "django-guardian", specifier = "==3.0.3" },
{ name = "django-model-utils", specifier = "==5.0.0" },
{ name = "django-pglock", specifier = "==1.7.2" },
{ name = "django-pgtrigger", specifier = "==4.15.2" },
@@ -300,7 +314,6 @@ requires-dist = [
{ name = "django-tenants", specifier = "==3.9.0" },
{ name = "djangoql", specifier = "==0.18.1" },
{ name = "djangorestframework", specifier = "==3.16.1" },
{ name = "djangorestframework-guardian", specifier = "==0.4.0" },
{ name = "docker", specifier = "==7.1.0" },
{ name = "drf-orjson-renderer", specifier = "==1.7.3" },
{ name = "drf-spectacular", specifier = "==0.28.0" },
@@ -1086,18 +1099,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/07/a6/70dcd68537c434ba7cb9277d403c5c829caf04f35baf5eb9458be251e382/django_filter-25.1-py3-none-any.whl", hash = "sha256:4fa48677cf5857b9b1347fed23e355ea792464e0fe07244d1fdfb8a806215b80", size = 94114, upload-time = "2025-02-14T16:30:50.435Z" },
]
[[package]]
name = "django-guardian"
version = "3.0.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "django" },
]
sdist = { url = "https://files.pythonhosted.org/packages/30/c2/3ed43813dd7313f729dbaa829b4f9ed4a647530151f672cfb5f843c12edf/django_guardian-3.0.3.tar.gz", hash = "sha256:4e59eab4d836da5a027cf0c176d14bc2a4e22cbbdf753159a03946c08c8a196d", size = 85410, upload-time = "2025-06-25T20:42:17.475Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8b/13/e6f629a978ef5fab8b8d2760cacc3e451016cef952cf4c049d672c5c6b07/django_guardian-3.0.3-py3-none-any.whl", hash = "sha256:d2164cea9f03c369d7ade21802710f3ab23ca6734bcc7dfcfb385906783916c7", size = 118198, upload-time = "2025-06-25T20:42:15.377Z" },
]
[[package]]
name = "django-model-utils"
version = "5.0.0"
@@ -1273,20 +1274,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b0/ce/bf8b9d3f415be4ac5588545b5fcdbbb841977db1c1d923f7568eeabe1689/djangorestframework-3.16.1-py3-none-any.whl", hash = "sha256:33a59f47fb9c85ede792cbf88bde71893bcda0667bc573f784649521f1102cec", size = 1080442, upload-time = "2025-08-06T17:50:50.667Z" },
]
[[package]]
name = "djangorestframework-guardian"
version = "0.4.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "django" },
{ name = "django-guardian" },
{ name = "djangorestframework" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c1/c4/67df9963395e9dddd4e16cbf75098953798e5135f73fb8f4855895505e39/djangorestframework_guardian-0.4.0.tar.gz", hash = "sha256:a8113659e062f65b74cc31af6982420c382642e782d38581b3fdc748a179756c", size = 8239, upload-time = "2025-07-01T07:22:10.809Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/59/81/3d62f7ff71f7c45ec6664ebf03a4c736bf77f49481604361d40f8f4471e4/djangorestframework_guardian-0.4.0-py3-none-any.whl", hash = "sha256:30c2a349318c1cd603d6953d50d58159f9a0c833f5f8f5a811407d5984a39e14", size = 6064, upload-time = "2025-07-01T07:22:09.661Z" },
]
[[package]]
name = "djangorestframework-stubs"
version = "3.16.3"

View File

@@ -21,7 +21,7 @@ import {
Application,
CoreApi,
OutpostsApi,
RbacPermissionsAssignedByUsersListModelEnum,
RbacPermissionsAssignedByRolesListModelEnum,
} from "@goauthentik/api";
import { msg, str } from "@lit/localize";
@@ -96,8 +96,8 @@ export class ApplicationViewPage extends AKElement {
if (
app.providerObj &&
[
RbacPermissionsAssignedByUsersListModelEnum.AuthentikProvidersProxyProxyprovider.toString(),
RbacPermissionsAssignedByUsersListModelEnum.AuthentikProvidersLdapLdapprovider.toString(),
RbacPermissionsAssignedByRolesListModelEnum.AuthentikProvidersProxyProxyprovider.toString(),
RbacPermissionsAssignedByRolesListModelEnum.AuthentikProvidersLdapLdapprovider.toString(),
].includes(app.providerObj.metaModelName)
) {
this.fetchIsMissingOutpost([app.provider || 0]);
@@ -407,7 +407,7 @@ export class ApplicationViewPage extends AKElement {
slot="page-permissions"
id="page-permissions"
aria-label="${msg("Permissions")}"
model=${RbacPermissionsAssignedByUsersListModelEnum.AuthentikCoreApplication}
model=${RbacPermissionsAssignedByRolesListModelEnum.AuthentikCoreApplication}
objectPk=${this.application.pk}
></ak-rbac-object-permission-page>
</ak-tabs>

View File

@@ -18,7 +18,7 @@ import { PolicyBindingCheckTarget } from "#admin/policies/utils";
import {
ApplicationEntitlement,
CoreApi,
RbacPermissionsAssignedByUsersListModelEnum,
RbacPermissionsAssignedByRolesListModelEnum,
} from "@goauthentik/api";
import { msg } from "@lit/localize";
@@ -93,7 +93,7 @@ export class ApplicationEntitlementsPage extends Table<ApplicationEntitlement> {
</button>
</ak-forms-modal>
<ak-rbac-object-permission-modal
model=${RbacPermissionsAssignedByUsersListModelEnum.AuthentikCoreApplicationentitlement}
model=${RbacPermissionsAssignedByRolesListModelEnum.AuthentikCoreApplicationentitlement}
objectPk=${item.pbmUuid}
>
</ak-rbac-object-permission-modal>`,

View File

@@ -21,7 +21,7 @@ import {
BlueprintInstanceStatusEnum,
ManagedApi,
ModelEnum,
RbacPermissionsAssignedByUsersListModelEnum,
RbacPermissionsAssignedByRolesListModelEnum,
} from "@goauthentik/api";
import { msg, str } from "@lit/localize";
@@ -171,7 +171,7 @@ export class BlueprintListPage extends TablePage<BlueprintInstance> {
</ak-forms-modal>
<ak-rbac-object-permission-modal
label=${item.name}
model=${RbacPermissionsAssignedByUsersListModelEnum.AuthentikBlueprintsBlueprintinstance}
model=${RbacPermissionsAssignedByRolesListModelEnum.AuthentikBlueprintsBlueprintinstance}
objectPk=${item.pk}
>
</ak-rbac-object-permission-modal>

View File

@@ -12,7 +12,7 @@ import { PaginatedResponse, TableColumn } from "#elements/table/Table";
import { TablePage } from "#elements/table/TablePage";
import { SlottedTemplateResult } from "#elements/types";
import { Brand, CoreApi, RbacPermissionsAssignedByUsersListModelEnum } from "@goauthentik/api";
import { Brand, CoreApi, RbacPermissionsAssignedByRolesListModelEnum } from "@goauthentik/api";
import { msg } from "@lit/localize";
import { html, TemplateResult } from "lit";
@@ -90,7 +90,7 @@ export class BrandListPage extends TablePage<Brand> {
</ak-forms-modal>
<ak-rbac-object-permission-modal
model=${RbacPermissionsAssignedByUsersListModelEnum.AuthentikBrandsBrand}
model=${RbacPermissionsAssignedByRolesListModelEnum.AuthentikBrandsBrand}
objectPk=${item.brandUuid}
>
</ak-rbac-object-permission-modal>

View File

@@ -17,7 +17,7 @@ import { SlottedTemplateResult } from "#elements/types";
import {
CertificateKeyPair,
CryptoApi,
RbacPermissionsAssignedByUsersListModelEnum,
RbacPermissionsAssignedByRolesListModelEnum,
} from "@goauthentik/api";
import { msg, str } from "@lit/localize";
@@ -125,7 +125,7 @@ export class CertificateKeyPairListPage extends TablePage<CertificateKeyPair> {
</button>
</ak-forms-modal>
<ak-rbac-object-permission-modal
model=${RbacPermissionsAssignedByUsersListModelEnum.AuthentikCryptoCertificatekeypair}
model=${RbacPermissionsAssignedByRolesListModelEnum.AuthentikCryptoCertificatekeypair}
objectPk=${item.pk}
>
</ak-rbac-object-permission-modal>

View File

@@ -14,7 +14,7 @@ import { setPageDetails } from "#components/ak-page-navbar";
import {
AgentConnector,
EndpointsApi,
RbacPermissionsAssignedByUsersListModelEnum,
RbacPermissionsAssignedByRolesListModelEnum,
} from "@goauthentik/api";
import { msg } from "@lit/localize";
@@ -125,7 +125,7 @@ export class AgentConnectorViewPage extends AKElement {
slot="page-permissions"
id="page-permissions"
aria-label="${msg("Permissions")}"
model=${RbacPermissionsAssignedByUsersListModelEnum.AuthentikEndpointsConnectorsAgentAgentconnector}
model=${RbacPermissionsAssignedByRolesListModelEnum.AuthentikEndpointsConnectorsAgentAgentconnector}
objectPk=${this.connector.connectorUuid!}
></ak-rbac-object-permission-page>
</ak-tabs> `;

View File

@@ -16,7 +16,7 @@ import {
AgentConnector,
EndpointsApi,
EnrollmentToken,
RbacPermissionsAssignedByUsersListModelEnum,
RbacPermissionsAssignedByRolesListModelEnum,
} from "@goauthentik/api";
import { msg } from "@lit/localize";
@@ -101,7 +101,7 @@ export class EnrollmentTokenListPage extends Table<EnrollmentToken> {
</button>
</ak-forms-modal>
<ak-rbac-object-permission-modal
model=${RbacPermissionsAssignedByUsersListModelEnum.AuthentikEndpointsConnectorsAgentEnrollmenttoken}
model=${RbacPermissionsAssignedByRolesListModelEnum.AuthentikEndpointsConnectorsAgentEnrollmenttoken}
objectPk=${item.tokenUuid}
>
</ak-rbac-object-permission-modal>

View File

@@ -22,7 +22,7 @@ import {
LicenseForecast,
LicenseSummary,
LicenseSummaryStatusEnum,
RbacPermissionsAssignedByUsersListModelEnum,
RbacPermissionsAssignedByRolesListModelEnum,
} from "@goauthentik/api";
import { msg, str } from "@lit/localize";
@@ -232,7 +232,7 @@ export class EnterpriseLicenseListPage extends TablePage<License> {
</button>
</ak-forms-modal>
<ak-rbac-object-permission-modal
model=${RbacPermissionsAssignedByUsersListModelEnum.AuthentikEnterpriseLicense}
model=${RbacPermissionsAssignedByRolesListModelEnum.AuthentikEnterpriseLicense}
objectPk=${item.licenseUuid}
>
</ak-rbac-object-permission-modal>

View File

@@ -19,7 +19,7 @@ import {
EventsApi,
ModelEnum,
NotificationRule,
RbacPermissionsAssignedByUsersListModelEnum,
RbacPermissionsAssignedByRolesListModelEnum,
} from "@goauthentik/api";
import { msg } from "@lit/localize";
@@ -100,7 +100,7 @@ export class RuleListPage extends TablePage<NotificationRule> {
</ak-forms-modal>
<ak-rbac-object-permission-modal
model=${RbacPermissionsAssignedByUsersListModelEnum.AuthentikEventsNotificationrule}
model=${RbacPermissionsAssignedByRolesListModelEnum.AuthentikEventsNotificationrule}
objectPk=${item.pk}
>
</ak-rbac-object-permission-modal>

View File

@@ -17,7 +17,7 @@ import {
EventsApi,
ModelEnum,
NotificationTransport,
RbacPermissionsAssignedByUsersListModelEnum,
RbacPermissionsAssignedByRolesListModelEnum,
} from "@goauthentik/api";
import { msg } from "@lit/localize";
@@ -92,7 +92,7 @@ export class TransportListPage extends TablePage<NotificationTransport> {
</ak-forms-modal>
<ak-rbac-object-permission-modal
model=${RbacPermissionsAssignedByUsersListModelEnum.AuthentikEventsNotificationtransport}
model=${RbacPermissionsAssignedByRolesListModelEnum.AuthentikEventsNotificationtransport}
objectPk=${item.pk}
>
</ak-rbac-object-permission-modal>

View File

@@ -15,7 +15,7 @@ import { SlottedTemplateResult } from "#elements/types";
import {
FlowsApi,
FlowStageBinding,
RbacPermissionsAssignedByUsersListModelEnum,
RbacPermissionsAssignedByRolesListModelEnum,
} from "@goauthentik/api";
import { msg, str } from "@lit/localize";
@@ -110,7 +110,7 @@ export class BoundStagesList extends Table<FlowStageBinding> {
</button>
</ak-forms-modal>
<ak-rbac-object-permission-modal
model=${RbacPermissionsAssignedByUsersListModelEnum.AuthentikFlowsFlowstagebinding}
model=${RbacPermissionsAssignedByRolesListModelEnum.AuthentikFlowsFlowstagebinding}
objectPk=${item.pk}
>
</ak-rbac-object-permission-modal>`,

View File

@@ -17,7 +17,7 @@ import { setPageDetails } from "#components/ak-page-navbar";
import { DesignationToLabel } from "#admin/flows/utils";
import { Flow, FlowsApi, RbacPermissionsAssignedByUsersListModelEnum } from "@goauthentik/api";
import { Flow, FlowsApi, RbacPermissionsAssignedByRolesListModelEnum } from "@goauthentik/api";
import { msg, str } from "@lit/localize";
import { css, CSSResult, html, nothing, PropertyValues } from "lit";
@@ -305,7 +305,7 @@ export class FlowViewPage extends AKElement {
slot="page-permissions"
id="page-permissions"
aria-label="${msg("Permissions")}"
model=${RbacPermissionsAssignedByUsersListModelEnum.AuthentikFlowsFlow}
model=${RbacPermissionsAssignedByRolesListModelEnum.AuthentikFlowsFlow}
objectPk=${this.flow.pk}
></ak-rbac-object-permission-page>
</ak-tabs>

View File

@@ -13,7 +13,7 @@ import { DEFAULT_CONFIG } from "#common/api/config";
import { DataProvision, DualSelectPair } from "#elements/ak-dual-select/types";
import { ModelForm } from "#elements/forms/ModelForm";
import { CoreApi, CoreGroupsListRequest, Group, RbacApi, Role } from "@goauthentik/api";
import { CoreApi, Group, RbacApi, RelatedGroup, Role } from "@goauthentik/api";
import YAML from "yaml";
@@ -22,6 +22,9 @@ import { css, CSSResult, html, TemplateResult } from "lit";
import { customElement } from "lit/decorators.js";
import { ifDefined } from "lit/directives/if-defined.js";
export function coreGroupPair(item: Group | RelatedGroup): DualSelectPair {
return [item.pk, html`<div class="selection-main">${item.name}</div>`, item.name];
}
export function rbacRolePair(item: Role): DualSelectPair {
return [item.pk, html`<div class="selection-main">${item.name}</div>`, item.name];
}
@@ -40,10 +43,38 @@ export class GroupForm extends ModelForm<Group, string> {
`,
];
#fetchGroups = (page: number, search?: string): Promise<DataProvision> => {
return new CoreApi(DEFAULT_CONFIG)
.coreGroupsList({
page: page,
search: search,
})
.then((results) => {
return {
pagination: results.pagination,
options: results.results.map(coreGroupPair),
};
});
};
#fetchRoles = (page: number, search?: string): Promise<DataProvision> => {
return new RbacApi(DEFAULT_CONFIG)
.rbacRolesList({
page: page,
search: search,
})
.then((results) => {
return {
pagination: results.pagination,
options: results.results.map(rbacRolePair),
};
});
};
loadInstance(pk: string): Promise<Group> {
return new CoreApi(DEFAULT_CONFIG).coreGroupsRetrieve({
groupUuid: pk,
includeUsers: false,
includeParents: true,
});
}
@@ -86,53 +117,23 @@ export class GroupForm extends ModelForm<Group, string> {
>
</ak-switch-input>
<ak-form-element-horizontal label=${msg("Parent Group")} name="parent">
<ak-search-select
placeholder=${msg("Select an optional parent group...")}
.fetchObjects=${async (query?: string): Promise<Group[]> => {
const args: CoreGroupsListRequest = {
ordering: "name",
};
if (query !== undefined) {
args.search = query;
}
const groups = await new CoreApi(DEFAULT_CONFIG).coreGroupsList(args);
if (this.instance) {
return groups.results.filter((g) => g.pk !== this.instance?.pk);
}
return groups.results;
}}
.renderElement=${(group: Group): string => {
return group.name;
}}
.value=${(group: Group | undefined): string | undefined => {
return group?.pk;
}}
.selected=${(group: Group): boolean => {
return group.pk === this.instance?.parent;
}}
blankable
>
</ak-search-select>
<ak-form-element-horizontal label=${msg("Parents")} name="parents">
<ak-dual-select-provider
.provider=${this.#fetchGroups}
.selected=${(this.instance?.parentsObj ?? []).map(coreGroupPair)}
available-label=${msg("Available Groups")}
selected-label=${msg("Selected Groups")}
></ak-dual-select-provider>
<p class="pf-c-form__helper-text">
${msg("A group recursively inherits every role from its ancestors.")}
</p>
</ak-form-element-horizontal>
<ak-form-element-horizontal label=${msg("Roles")} name="roles">
<ak-dual-select-provider
.provider=${(page: number, search?: string): Promise<DataProvision> => {
return new RbacApi(DEFAULT_CONFIG)
.rbacRolesList({
page: page,
search: search,
})
.then((results) => {
return {
pagination: results.pagination,
options: results.results.map(rbacRolePair),
};
});
}}
.provider=${this.#fetchRoles}
.selected=${(this.instance?.rolesObj ?? []).map(rbacRolePair)}
available-label="${msg("Available Roles")}"
selected-label="${msg("Selected Roles")}"
available-label=${msg("Available Roles")}
selected-label=${msg("Selected Roles")}
></ak-dual-select-provider>
<p class="pf-c-form__helper-text">
${msg(

View File

@@ -43,7 +43,6 @@ export class GroupListPage extends TablePage<Group> {
protected columns: TableColumn[] = [
[msg("Name"), "name"],
[msg("Parent"), "parent"],
[msg("Members")],
[msg("Superuser privileges?")],
[msg("Actions"), null, msg("Row Actions")],
@@ -78,7 +77,6 @@ export class GroupListPage extends TablePage<Group> {
aria-label=${msg(str`View details of group "${item.name}"`)}
>${item.name}</a
>`,
html`${item.parentName || msg("-")}`,
html`${Array.from(item.users || []).length}`,
html`<ak-status-label type="neutral" ?good=${item.isSuperuser}></ak-status-label>`,
html`<div>

Some files were not shown because too many files have changed in this diff Show More