diff --git a/authentik/core/api/groups.py b/authentik/core/api/groups.py index 2777020a2..2ab4ff7f5 100644 --- a/authentik/core/api/groups.py +++ b/authentik/core/api/groups.py @@ -2,13 +2,20 @@ from json import loads from django.db.models.query import QuerySet +from django.http import Http404 from django_filters.filters import CharFilter, ModelMultipleChoiceFilter from django_filters.filterset import FilterSet +from drf_spectacular.utils import OpenApiResponse, extend_schema, inline_serializer +from guardian.shortcuts import get_objects_for_user +from rest_framework.decorators import action from rest_framework.fields import CharField, IntegerField, JSONField +from rest_framework.request import Request +from rest_framework.response import Response from rest_framework.serializers import ListSerializer, ModelSerializer, ValidationError from rest_framework.viewsets import ModelViewSet from rest_framework_guardian.filters import ObjectPermissionsFilter +from authentik.api.decorators import permission_required from authentik.core.api.used_by import UsedByMixin from authentik.core.api.utils import is_dict from authentik.core.models import Group, User @@ -134,3 +141,63 @@ class GroupViewSet(UsedByMixin, ModelViewSet): if self.request.user.has_perm("authentik_core.view_group"): return self._filter_queryset_for_list(queryset) return super().filter_queryset(queryset) + + @permission_required(None, ["authentik_core.add_user"]) + @extend_schema( + request=inline_serializer( + "UserAccountSerializer", + { + "pk": IntegerField(required=True), + }, + ), + responses={ + 204: OpenApiResponse(description="User added"), + 404: OpenApiResponse(description="User not found"), + }, + ) + @action(detail=True, methods=["POST"], pagination_class=None, filter_backends=[]) + # pylint: disable=unused-argument, invalid-name + def add_user(self, request: Request, pk: str) -> Response: + """Add user to group""" + group: Group = 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 + group.users.add(user) + return Response(status=204) + + @permission_required(None, ["authentik_core.add_user"]) + @extend_schema( + request=inline_serializer( + "UserAccountSerializer", + { + "pk": IntegerField(required=True), + }, + ), + responses={ + 204: OpenApiResponse(description="User added"), + 404: OpenApiResponse(description="User not found"), + }, + ) + @action(detail=True, methods=["POST"], pagination_class=None, filter_backends=[]) + # pylint: disable=unused-argument, invalid-name + def remove_user(self, request: Request, pk: str) -> Response: + """Add user to group""" + group: Group = 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 + group.users.remove(user) + return Response(status=204) diff --git a/authentik/core/tests/test_groups_api.py b/authentik/core/tests/test_groups_api.py new file mode 100644 index 000000000..b4479e451 --- /dev/null +++ b/authentik/core/tests/test_groups_api.py @@ -0,0 +1,69 @@ +"""Test Groups API""" +from django.urls.base import reverse +from rest_framework.test import APITestCase + +from authentik.core.models import Group, User +from authentik.core.tests.utils import create_test_admin_user +from authentik.lib.generators import generate_id + + +class TestGroupsAPI(APITestCase): + """Test Groups API""" + + def setUp(self) -> None: + self.admin = create_test_admin_user() + self.user = User.objects.create(username="test-user") + + def test_add_user(self): + """Test add_user""" + group = Group.objects.create(name=generate_id()) + self.client.force_login(self.admin) + res = self.client.post( + reverse("authentik_api:group-add-user", kwargs={"pk": group.pk}), + data={ + "pk": self.user.pk, + }, + ) + self.assertEqual(res.status_code, 204) + group.refresh_from_db() + self.assertEqual(list(group.users.all()), [self.user]) + + def test_add_user_404(self): + """Test add_user""" + group = Group.objects.create(name=generate_id()) + self.client.force_login(self.admin) + res = self.client.post( + reverse("authentik_api:group-add-user", kwargs={"pk": group.pk}), + data={ + "pk": self.user.pk + 3, + }, + ) + self.assertEqual(res.status_code, 404) + + def test_remove_user(self): + """Test remove_user""" + group = Group.objects.create(name=generate_id()) + group.users.add(self.user) + self.client.force_login(self.admin) + res = self.client.post( + reverse("authentik_api:group-remove-user", kwargs={"pk": group.pk}), + data={ + "pk": self.user.pk, + }, + ) + self.assertEqual(res.status_code, 204) + group.refresh_from_db() + self.assertEqual(list(group.users.all()), []) + + def test_remove_user_404(self): + """Test remove_user""" + group = Group.objects.create(name=generate_id()) + group.users.add(self.user) + self.client.force_login(self.admin) + res = self.client.post( + reverse("authentik_api:group-remove-user", kwargs={"pk": group.pk}), + data={ + "pk": self.user.pk + 3, + }, + ) + self.assertEqual(res.status_code, 404) diff --git a/schema.yml b/schema.yml index b95548960..00d64731f 100644 --- a/schema.yml +++ b/schema.yml @@ -3458,6 +3458,84 @@ paths: schema: $ref: '#/components/schemas/GenericError' description: '' + /core/groups/{group_uuid}/add_user/: + post: + operationId: core_groups_add_user_create + description: Add user to group + parameters: + - in: path + name: group_uuid + schema: + type: string + format: uuid + description: A UUID string identifying this group. + required: true + tags: + - core + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/UserAccountRequest' + required: true + security: + - authentik: [] + responses: + '204': + description: User added + '404': + description: User not found + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' + /core/groups/{group_uuid}/remove_user/: + post: + operationId: core_groups_remove_user_create + description: Add user to group + parameters: + - in: path + name: group_uuid + schema: + type: string + format: uuid + description: A UUID string identifying this group. + required: true + tags: + - core + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/UserAccountRequest' + required: true + security: + - authentik: [] + responses: + '204': + description: User added + '404': + description: User not found + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' /core/groups/{group_uuid}/used_by/: get: operationId: core_groups_used_by_list @@ -37825,6 +37903,13 @@ components: - pk - uid - username + UserAccountRequest: + type: object + properties: + pk: + type: integer + required: + - pk UserConsent: type: object description: UserConsent Serializer diff --git a/web/src/admin/groups/RelatedGroupList.ts b/web/src/admin/groups/RelatedGroupList.ts index a7e188a9d..d85b8eae4 100644 --- a/web/src/admin/groups/RelatedGroupList.ts +++ b/web/src/admin/groups/RelatedGroupList.ts @@ -52,17 +52,15 @@ export class RelatedGroupList extends Table { return html` { - const newGroups = this.targetUser?.groups.filter((group) => { - return group != item.pk; - }); - return new CoreApi(DEFAULT_CONFIG).coreUsersPartialUpdate({ - id: this.targetUser?.pk || 0, - patchedUserRequest: { - groups: newGroups, - }, + if (!this.targetUser) return; + return new CoreApi(DEFAULT_CONFIG).coreGroupsRemoveUserCreate({ + groupUuid: item.pk, + userAccountRequest: { + pk: this.targetUser?.pk || 0, + } }); }} > diff --git a/web/src/admin/users/RelatedUserList.ts b/web/src/admin/users/RelatedUserList.ts index 9079ba844..13928fd9c 100644 --- a/web/src/admin/users/RelatedUserList.ts +++ b/web/src/admin/users/RelatedUserList.ts @@ -88,16 +88,11 @@ export class RelatedUserList extends Table { ]; }} .delete=${(item: User) => { - const newUsers = this.targetGroup?.usersObj - .filter((user) => { - return user.pk != item.pk; - }) - .map((user) => user.pk); - return new CoreApi(DEFAULT_CONFIG).coreGroupsPartialUpdate({ + return new CoreApi(DEFAULT_CONFIG).coreGroupsRemoveUserCreate({ groupUuid: this.targetGroup?.pk || "", - patchedGroupRequest: { - users: newUsers, - }, + userAccountRequest: { + pk: item.pk, + } }); }} > diff --git a/website/docs/releases/v2022.12.md b/website/docs/releases/v2022.12.md index ee0988652..14df94b46 100644 --- a/website/docs/releases/v2022.12.md +++ b/website/docs/releases/v2022.12.md @@ -88,7 +88,7 @@ image: - stages/invitation: fix incorrect pk check for invitation's flow - stages/user_login: prevent double success message when logging in via source - stages/user_write: always ignore `component` field and prevent warning -- web: fix authentification with Plex on iOS (#4095) +- web: fix authentication with Plex on iOS (#4095) - web: ignore d3 circular deps warning, treat unresolved import as error - web: use version family subdomain for in-app doc links - web/admin: better show metadata download for saml provider