diff --git a/docs/user/rest-api.rst b/docs/user/rest-api.rst index 79528090..0efc1882 100644 --- a/docs/user/rest-api.rst +++ b/docs/user/rest-api.rst @@ -961,3 +961,90 @@ DELETE ^^^^^^ Deletes a RADIUS group identified by its UUID. + +RADIUS User Groups +++++++++++++++++++ + +.. code-block:: text + + /api/v1/users/user//radius-group/ + +GET +^^^ + +Returns the list of RADIUS group assignments for the specified user. +Pagination is provided using page number pagination; default page size is +20 and can be overridden with the ``page_size`` parameter (maximum 100). + +.. code-block:: text + + GET /api/v1/users/user//radius-group/ + +It supports filtering by organization id and organization slug. + +Filters +""""""" + +========================= ============================ +Filter Parameter Description +========================= ============================ +group__organization Filter organizations by id +group__organization__slug Filter organizations by slug +========================= ============================ + +POST +^^^^ + +Creates a RADIUS user group assignment for the specified user. + +======== ============================================== +Param Description +======== ============================================== +group UUID of the RADIUS group to assign (required) +priority Priority of the assignment (optional, integer) +======== ============================================== + +.. note:: + + The provided ``group`` must belong to the same organization as the + user; attempting to assign a group from another organization will + return a ``400`` error with ``does_not_exist`` for the ``group`` + field. + +.. code-block:: text + + /api/v1/users/user//radius-group// + +GET (detail) +^^^^^^^^^^^^ + +Returns a single RADIUS user group assignment by its UUID. + +PUT +^^^ + +Fully updates the RADIUS user group assignment. + +======== ============================================== +Param Description +======== ============================================== +group UUID of the RADIUS group to assign (optional) +priority Priority of the assignment (optional, integer) +======== ============================================== + +PATCH +^^^^^ + +Partially updates a RADIUS user group assignment. + +======== ============================================== +Param Description +======== ============================================== +group UUID of the RADIUS group to assign (optional) +priority Priority of the assignment (optional, integer) +======== ============================================== + +DELETE +^^^^^^ + +Deletes the RADIUS user group assignment identified by the UUID. diff --git a/openwisp_radius/api/serializers.py b/openwisp_radius/api/serializers.py index ae7cc3e2..b9b01165 100644 --- a/openwisp_radius/api/serializers.py +++ b/openwisp_radius/api/serializers.py @@ -365,6 +365,43 @@ def validate(self, data): return data +class RadiusUserGroupSerializer(FilterSerializerByOrgManaged, ValidatedModelSerializer): + class Meta: + model = RadiusUserGroup + fields = ("id", "group", "priority", "created", "modified") + read_only_fields = ("id", "created", "modified") + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + view = self.context.get("view") + if ( + view + and getattr(view, "get_parent_queryset", None) + and not getattr(view, "swagger_fake_view", False) + ): + self._user = view.get_parent_queryset().first() + else: + self._user = None + if self._user and view and getattr(view.request, "user", None): + # Restrict available groups to organizations that the request user manages + # and that the edited user belongs to. This prevents assigning groups from + # organizations outside the request user's management scope. + self.fields["group"].queryset = self.fields["group"].queryset.filter( + Q(organization__in=view.request.user.organizations_managed) + & Q(organization__in=self._user.organizations_dict.keys()) + ) + else: + self.fields["group"].queryset = self.fields["group"].queryset.none() + + def validate(self, data): + if self._user: + if "username" not in data: + data["username"] = self._user.username + if "user" not in data: + data["user"] = self._user + return super().validate(data) + + class GroupSerializer(serializers.ModelSerializer): class Meta: model = Group diff --git a/openwisp_radius/api/urls.py b/openwisp_radius/api/urls.py index dfeeab65..88d02572 100644 --- a/openwisp_radius/api/urls.py +++ b/openwisp_radius/api/urls.py @@ -98,6 +98,16 @@ def get_api_urls(api_views=None): api_views.radius_group_detail, name="radius_group_detail", ), + path( + "users/user//radius-group/", + api_views.radius_user_group_list, + name="radius_user_group_list", + ), + path( + "users/user//radius-group//", + api_views.radius_user_group_detail, + name="radius_user_group_detail", + ), ] else: return [] diff --git a/openwisp_radius/api/views.py b/openwisp_radius/api/views.py index fba092e9..07c4bd37 100644 --- a/openwisp_radius/api/views.py +++ b/openwisp_radius/api/views.py @@ -48,7 +48,11 @@ from openwisp_radius.api.serializers import RadiusUserSerializer from openwisp_users.api.authentication import BearerAuthentication, SesameAuthentication from openwisp_users.api.filters import OrganizationManagedFilter -from openwisp_users.api.mixins import FilterByOrganizationManaged, ProtectedAPIMixin +from openwisp_users.api.mixins import ( + FilterByOrganizationManaged, + FilterByParentManaged, + ProtectedAPIMixin, +) from openwisp_users.api.permissions import IsOrganizationManager from openwisp_users.api.views import ChangePasswordView as BasePasswordChangeView from openwisp_users.backends import UsersAuthenticationBackend @@ -69,6 +73,7 @@ RadiusAccountingSerializer, RadiusBatchSerializer, RadiusGroupSerializer, + RadiusUserGroupSerializer, UserRadiusUsageSerializer, ValidatePhoneTokenSerializer, ) @@ -936,3 +941,123 @@ class RadiusGroupDetailView( radius_group_detail = RadiusGroupDetailView.as_view() + + +class BaseRadiusUserGroupView(ProtectedAPIMixin, FilterByParentManaged): + """ + Base view for RadiusUserGroup management. + Provides user parent filtering and queryset logic. + """ + + serializer_class = RadiusUserGroupSerializer + queryset = RadiusUserGroup.objects.select_related("group", "user").order_by( + "-created" + ) + + def get_queryset(self): + if getattr(self, "swagger_fake_view", False): + return self.queryset.none() + qs = ( + super() + .get_queryset() + .filter( + user_id=self.kwargs["user_pk"], + ) + ) + if self.request.user.is_superuser: + return qs + return qs.filter( + group__organization__in=self.request.user.organizations_managed + ) + + def get_parent_queryset(self): + return User.objects.filter(pk=self.kwargs["user_pk"]) + + def get_organization_queryset(self, qs): + """Filter users by organizations the request user manages.""" + orgs = self.request.user.organizations_managed + app_label = User._meta.app_config.label + filter_kwargs = { + # exclude superusers + "is_superuser": False, + # ensure user is member of the org + f"{app_label}_organizationuser__organization_id__in": orgs, + } + return qs.filter(**filter_kwargs).distinct() + + +class RadiusUserGroupFilter(OrganizationManagedFilter, filters.FilterSet): + """ + Filter RADIUS groups by organizations managed by the user. + """ + + # Disable parent's organization_slug; use group__organization__slug instead + organization_slug = None + + class Meta(OrganizationManagedFilter.Meta): + model = RadiusUserGroup + fields = ["group__organization", "group__organization__slug"] + + +@method_decorator( + name="get", + decorator=swagger_auto_schema( + operation_description=""" + Returns the list of RADIUS user groups for a specific user. + """, + ), +) +@method_decorator( + name="post", + decorator=swagger_auto_schema( + operation_description=""" + Creates a new RADIUS user group assignment for the user. + """, + ), +) +class RadiusUserGroupListCreateView(BaseRadiusUserGroupView, ListCreateAPIView): + pagination_class = RadiusGroupPaginator + filter_backends = [DjangoFilterBackend] + filterset_class = RadiusUserGroupFilter + + +radius_user_group_list = RadiusUserGroupListCreateView.as_view() + + +@method_decorator( + name="get", + decorator=swagger_auto_schema( + operation_description=""" + Returns a single RADIUS user group by its UUID. + """, + ), +) +@method_decorator( + name="put", + decorator=swagger_auto_schema( + operation_description=""" + Updates a RADIUS user group identified by its UUID. + """, + ), +) +@method_decorator( + name="patch", + decorator=swagger_auto_schema( + operation_description=""" + Partially updates a RADIUS user group identified by its UUID. + """, + ), +) +@method_decorator( + name="delete", + decorator=swagger_auto_schema( + operation_description=""" + Deletes a RADIUS user group identified by its UUID. + """, + ), +) +class RadiusUserGroupDetailView(BaseRadiusUserGroupView, RetrieveUpdateDestroyAPIView): + organization_field = "group__organization" + + +radius_user_group_detail = RadiusUserGroupDetailView.as_view() diff --git a/openwisp_radius/tests/test_api/test_api.py b/openwisp_radius/tests/test_api/test_api.py index a0148b21..d0a6f3d5 100644 --- a/openwisp_radius/tests/test_api/test_api.py +++ b/openwisp_radius/tests/test_api/test_api.py @@ -26,6 +26,7 @@ from openwisp_radius import settings as app_settings from openwisp_radius.api.serializers import ( + RadiusUserGroupSerializer, RadiusUserSerializer, UserGroupCheckSerializer, ) @@ -1292,6 +1293,269 @@ def test_radius_group_detail(self): response = self.client.delete(url_org2) self.assertEqual(response.status_code, 404) + def test_radius_user_group_list(self): + org1 = self._create_org(name="Org 1") + org2 = self._create_org(name="Org 2") + admin_user = self._create_user(username="admin_user", email="admin@test.com") + self._create_org_user(user=admin_user, organization=org1, is_admin=True) + target_user = self._create_user(username="target_user", email="target@test.com") + self._create_org_user(user=target_user, organization=org1) + self._create_org_user(user=target_user, organization=org2) + # Create a user in org2 that admin_user should not be able to access + org2_user = self._create_user(username="org2_user", email="org2@test.com") + self._create_org_user(user=org2_user, organization=org2) + org1_group = RadiusGroup.objects.get(organization=org1, name="org-1-users") + rug = self._create_radius_usergroup( + user=target_user, group=org1_group, priority=1 + ) + org2_group = RadiusGroup.objects.get(organization=org2, name="org-2-users") + self._create_radius_usergroup(user=target_user, group=org2_group, priority=1) + url = reverse("radius:radius_user_group_list", args=[target_user.pk]) + org2_user_url = reverse("radius:radius_user_group_list", args=[org2_user.pk]) + + with self.subTest("Unauthenticated access"): + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + self.client.force_login(user=admin_user) + with self.subTest("Access without permission"): + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + response = self.client.post( + url, {"group": str(org1_group.pk)}, content_type="application/json" + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + self._add_model_permission(admin_user, RadiusUserGroup, ["view", "add"]) + with self.subTest("List RadiusUserGroups"): + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + data = response.data + self.assertEqual(data["count"], 1) + self.assertEqual(data["results"][0]["id"], str(rug.pk)) + + with self.subTest("Filter by organization query parameter"): + # requesting the same organization that the existing group belongs to + response = self.client.get(url + f"?group__organization={org1.pk}") + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["count"], 1) + # a different organization should yield no results + response = self.client.get(url + f"?group__organization={org2.pk}") + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn("group__organization", response.data) + + with self.subTest("Create RadiusUserGroup"): + org1_power_users = RadiusGroup.objects.get( + organization=org1, name="org-1-power-users" + ) + response = self.client.post( + url, + {"group": str(org1_power_users.pk), "priority": 2}, + content_type="application/json", + ) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual( + RadiusUserGroup.objects.filter( + user=target_user, group__organization=org1 + ).count(), + 2, + ) + + with self.subTest("Cannot access user from other organization"): + response = self.client.get(org2_user_url) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + with self.subTest("Cannot create for user not in admin's organization"): + org2_user2 = self._create_user( + username="org2_user2", email="org2user2@test.com" + ) + self._create_org_user(user=org2_user2, organization=org2) + org2_user2_url = reverse( + "radius:radius_user_group_list", args=[org2_user2.pk] + ) + org1_group = RadiusGroup.objects.get( + organization=org1, name="org-1-power-users" + ) + response = self.client.post( + org2_user2_url, + {"group": str(org1_group.pk)}, + content_type="application/json", + ) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + with self.subTest("Cannot create RadiusUserGroup with group from other org"): + # target_user is member of org2, + # but admin_user can only manage org1 + org2_group = RadiusGroup.objects.get( + organization=org2, name="org-2-power-users" + ) + response = self.client.post( + url, + {"group": str(org2_group.pk)}, + content_type="application/json", + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn("group", response.data) + self.assertEqual(response.data["group"][0].code, "does_not_exist") + + # target_user is only member of org1, + # admin_user can manage both org1 and org2 + OrganizationUser.objects.filter( + user=target_user, organization=org2 + ).delete() + self._create_org_user(user=admin_user, organization=org2, is_admin=True) + response = self.client.post( + url, + {"group": str(org2_group.pk)}, + content_type="application/json", + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn("group", response.data) + self.assertEqual(response.data["group"][0].code, "does_not_exist") + + with self.subTest("Superuser can access any user"): + superuser = self._get_admin() + self.client.force_login(user=superuser) + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + response = self.client.get(org2_user_url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_radius_user_group_detail(self): + org1 = self._create_org(name="Org 1") + org2 = self._create_org(name="Org 2") + admin_user = self._create_user(username="admin_user", email="admin@test.com") + self._create_org_user(user=admin_user, organization=org1, is_admin=True) + target_user = self._create_user(username="target_user", email="target@test.com") + self._create_org_user(user=target_user, organization=org1) + org1_default_group = RadiusGroup.objects.get( + organization=org1, name="org-1-users" + ) + org1_power_users_group = RadiusGroup.objects.get( + organization=org1, name="org-1-power-users" + ) + rug = self._create_radius_usergroup( + user=target_user, group=org1_default_group, priority=1 + ) + url = reverse("radius:radius_user_group_detail", args=[target_user.pk, rug.pk]) + + with self.subTest("Unauthenticated access"): + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + response = self.client.patch( + url, {"priority": 5}, content_type="application/json" + ) + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + response = self.client.delete(url) + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + self.client.force_login(user=admin_user) + with self.subTest("Access without permission"): + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + self._add_model_permission( + admin_user, RadiusUserGroup, ["view", "change", "delete"] + ) + + with self.subTest("GET operation"): + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + data = response.json() + self.assertEqual(data["id"], str(rug.pk)) + self.assertEqual(data["group"], str(org1_default_group.pk)) + + with self.subTest("PATCH operation"): + response = self.client.patch( + url, {"priority": 10}, content_type="application/json" + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + rug.refresh_from_db() + self.assertEqual(rug.priority, 10) + + with self.subTest("PUT operation"): + response = self.client.put( + url, + {"group": str(org1_power_users_group.pk), "priority": 3}, + content_type="application/json", + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + rug.refresh_from_db() + self.assertEqual(rug.group, org1_power_users_group) + self.assertEqual(rug.priority, 3) + + with self.subTest("PUT without group field"): + response = self.client.put( + url, + {"priority": 4}, + content_type="application/json", + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + rug.refresh_from_db() + self.assertEqual(rug.group, org1_power_users_group) + self.assertEqual(rug.priority, 4) + + with self.subTest("Org manager cannot assign group from another org"): + self._create_org_user(user=target_user, organization=org2) + org2_group = RadiusGroup.objects.get(organization=org2, name="org-2-users") + response = self.client.put( + url, + {"group": str(org2_group.pk), "priority": 8}, + content_type="application/json", + ) + self.assertEqual( + response.status_code, + status.HTTP_400_BAD_REQUEST, + ) + rug.refresh_from_db() + self.assertEqual(rug.group, org1_power_users_group) + + with self.subTest("DELETE operation"): + response = self.client.delete(url) + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertEqual(RadiusUserGroup.objects.filter(pk=rug.pk).count(), 0) + + with self.subTest("GET not found"): + fake_uuid = "00000000-0000-0000-0000-000000000000" + fake_url = reverse( + "radius:radius_user_group_detail", args=[target_user.pk, fake_uuid] + ) + response = self.client.get(fake_url) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + with self.subTest("Cannot access RadiusUserGroup of user from other org"): + org2_user = self._create_user(username="org2_user", email="org2@test.com") + self._create_org_user(user=org2_user, organization=org2) + org2_rug = self._create_radius_usergroup( + user=org2_user, + group=RadiusGroup.objects.get(organization=org2, name="org-2-users"), + priority=1, + ) + org2_url = reverse( + "radius:radius_user_group_detail", args=[org2_user.pk, org2_rug.pk] + ) + response = self.client.get(org2_url) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + with self.subTest("Superuser can access any RadiusUserGroup"): + superuser = self._get_admin() + self.client.force_login(user=superuser) + response = self.client.get(org2_url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + # Patch RadiusUserGroup of org2_user + response = self.client.patch( + org2_url, {"priority": 7}, content_type="application/json" + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + org2_rug.refresh_from_db() + self.assertEqual(org2_rug.priority, 7) + + def test_radius_user_group_serializer_without_view_context(self): + serializer = RadiusUserGroupSerializer(context={}) + self.assertEqual(serializer._user, None) + self.assertEqual(serializer.fields["group"].queryset.count(), 0) + class TestTransactionApi(AcctMixin, ApiTokenMixin, BaseTransactionTestCase): def test_user_radius_usage_view(self): diff --git a/tests/openwisp2/sample_radius/api/views.py b/tests/openwisp2/sample_radius/api/views.py index f5338f4c..6bb68b99 100644 --- a/tests/openwisp2/sample_radius/api/views.py +++ b/tests/openwisp2/sample_radius/api/views.py @@ -17,7 +17,12 @@ ) from openwisp_radius.api.views import PasswordResetView as BasePasswordResetView from openwisp_radius.api.views import RadiusAccountingView as BaseRadiusAccountingView -from openwisp_radius.api.views import RadiusGroupDetailView, RadiusGroupListView +from openwisp_radius.api.views import ( + RadiusGroupDetailView, + RadiusGroupListView, + RadiusUserGroupDetailView, + RadiusUserGroupListCreateView, +) from openwisp_radius.api.views import RegisterView as BaseRegisterView from openwisp_radius.api.views import UserAccountingView as BaseUserAccountingView from openwisp_radius.api.views import UserRadiusUsageView as BaseUserRadiusUsageView @@ -119,3 +124,5 @@ class RadiusAccountingView(BaseRadiusAccountingView): radius_accounting = RadiusAccountingView.as_view() radius_group_list = RadiusGroupListView.as_view() radius_group_detail = RadiusGroupDetailView.as_view() +radius_user_group_list = RadiusUserGroupListCreateView.as_view() +radius_user_group_detail = RadiusUserGroupDetailView.as_view()