mirror of
https://github.com/goauthentik/authentik.git
synced 2026-08-30 18:51:39 -07:00
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:
@@ -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"""
|
||||
|
||||
@@ -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"})
|
||||
|
||||
@@ -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"""
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()),
|
||||
],
|
||||
|
||||
@@ -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),
|
||||
]
|
||||
180
authentik/core/migrations/0056_user_roles.py
Normal file
180
authentik/core/migrations/0056_user_roles.py
Normal 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"),
|
||||
),
|
||||
]
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
|
||||
20
authentik/core/tests/test_users.py
Normal file
20
authentik/core/tests/test_users.py
Normal 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))
|
||||
@@ -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(
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -24,7 +24,6 @@ class InitialPermissionsSerializer(ModelSerializer):
|
||||
fields = [
|
||||
"pk",
|
||||
"name",
|
||||
"mode",
|
||||
"role",
|
||||
"permissions",
|
||||
"permissions_obj",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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"""
|
||||
|
||||
33
authentik/rbac/migrations/0008_alter_role_group.py
Normal file
33
authentik/rbac/migrations/0008_alter_role_group.py
Normal 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",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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",
|
||||
),
|
||||
]
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
)
|
||||
@@ -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={
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
],
|
||||
},
|
||||
)
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"""
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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 {
|
||||
|
||||
429
packages/ak-guardian/LICENSE
Normal file
429
packages/ak-guardian/LICENSE
Normal 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.
|
||||
```
|
||||
1
packages/ak-guardian/README.md
Normal file
1
packages/ak-guardian/README.md
Normal file
@@ -0,0 +1 @@
|
||||
This is a fork of django-guardian.
|
||||
25
packages/ak-guardian/guardian/__init__.py
Normal file
25
packages/ak-guardian/guardian/__init__.py
Normal 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")
|
||||
12
packages/ak-guardian/guardian/apps.py
Normal file
12
packages/ak-guardian/guardian/apps.py
Normal 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)
|
||||
127
packages/ak-guardian/guardian/backends.py
Normal file
127
packages/ak-guardian/guardian/backends.py
Normal 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)
|
||||
20
packages/ak-guardian/guardian/checks.py
Normal file
20
packages/ak-guardian/guardian/checks.py
Normal 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
|
||||
0
packages/ak-guardian/guardian/conf/__init__.py
Normal file
0
packages/ak-guardian/guardian/conf/__init__.py
Normal file
31
packages/ak-guardian/guardian/conf/settings.py
Normal file
31
packages/ak-guardian/guardian/conf/settings.py
Normal 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")
|
||||
127
packages/ak-guardian/guardian/core.py
Normal file
127
packages/ak-guardian/guardian/core.py
Normal 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)
|
||||
28
packages/ak-guardian/guardian/ctypes.py
Normal file
28
packages/ak-guardian/guardian/ctypes.py
Normal 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)
|
||||
40
packages/ak-guardian/guardian/exceptions.py
Normal file
40
packages/ak-guardian/guardian/exceptions.py
Normal 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
|
||||
56
packages/ak-guardian/guardian/management/__init__.py
Normal file
56
packages/ak-guardian/guardian/management/__init__.py
Normal 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",
|
||||
)
|
||||
@@ -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")
|
||||
108
packages/ak-guardian/guardian/managers.py
Normal file
108
packages/ak-guardian/guardian/managers.py
Normal 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
|
||||
61
packages/ak-guardian/guardian/migrations/0001_initial.py
Normal file
61
packages/ak-guardian/guardian/migrations/0001_initial.py
Normal 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")},
|
||||
),
|
||||
]
|
||||
@@ -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"
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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"
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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")},
|
||||
},
|
||||
),
|
||||
]
|
||||
152
packages/ak-guardian/guardian/models.py
Normal file
152
packages/ak-guardian/guardian/models.py
Normal 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"]),
|
||||
]
|
||||
336
packages/ak-guardian/guardian/shortcuts.py
Normal file
336
packages/ak-guardian/guardian/shortcuts.py
Normal 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
|
||||
348
packages/ak-guardian/guardian/utils.py
Normal file
348
packages/ak-guardian/guardian/utils.py
Normal 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
|
||||
60
packages/ak-guardian/pyproject.toml
Normal file
60
packages/ak-guardian/pyproject.toml
Normal 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 = {}
|
||||
@@ -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.*",
|
||||
|
||||
951
schema.yml
951
schema.yml
File diff suppressed because it is too large
Load Diff
@@ -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
47
uv.lock
generated
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>`,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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> `;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>`,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user