Skip to content

Commit 3348080

Browse files
asmodehnAlexandre Vincentpandafy
authored
[fix] Fixed DeviceConnection and Command API endpoints for non-superuser
Bug: When an organization admin (non-superuser) attempted to retrieve command details via the REST API, the request resulted in a 500 server error. Fix: Configured `organization_field` and `organization_lookup` in `BaseCommandView`, ensuring that `FilterByParent` and `DjangoModelPermissions` are applied correctly. --------- Co-authored-by: Alexandre Vincent <[email protected]> Co-authored-by: Gagan Deep <[email protected]>
1 parent 034c2be commit 3348080

File tree

3 files changed

+209
-64
lines changed

3 files changed

+209
-64
lines changed

openwisp_controller/connection/api/views.py

Lines changed: 15 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,15 @@
1-
from django.core.exceptions import ValidationError
21
from django.utils.translation import gettext_lazy as _
32
from drf_yasg import openapi
43
from drf_yasg.utils import swagger_auto_schema
54
from rest_framework import pagination
6-
from rest_framework.exceptions import NotFound
75
from rest_framework.generics import (
8-
GenericAPIView,
96
ListCreateAPIView,
107
RetrieveAPIView,
118
RetrieveUpdateDestroyAPIView,
129
get_object_or_404,
1310
)
1411
from swapper import load_model
1512

16-
from openwisp_users.api.mixins import FilterByParentManaged
17-
from openwisp_users.api.mixins import ProtectedAPIMixin as BaseProtectedAPIMixin
18-
1913
from ...mixins import (
2014
ProtectedAPIMixin,
2115
RelatedDeviceModelPermission,
@@ -39,10 +33,9 @@ class ListViewPagination(pagination.PageNumberPagination):
3933
max_page_size = 100
4034

4135

42-
class BaseCommandView(
43-
BaseProtectedAPIMixin,
44-
FilterByParentManaged,
45-
):
36+
class BaseCommandView(RelatedDeviceProtectedAPIMixin):
37+
organization_field = "device__organization"
38+
organization_lookup = "organization__in"
4639
model = Command
4740
queryset = Command.objects.prefetch_related("device")
4841
serializer_class = CommandSerializer
@@ -116,44 +109,35 @@ class CredentialDetailView(ProtectedAPIMixin, RetrieveUpdateDestroyAPIView):
116109
serializer_class = CredentialSerializer
117110

118111

119-
class BaseDeviceConnection(RelatedDeviceProtectedAPIMixin, GenericAPIView):
112+
class BaseDeviceConnection(
113+
RelatedDeviceProtectedAPIMixin,
114+
):
115+
organization_field = "device__organization"
116+
organization_lookup = "organization__in"
120117
model = DeviceConnection
121118
serializer_class = DeviceConnectionSerializer
119+
queryset = DeviceConnection.objects.prefetch_related("device")
122120

123121
def get_queryset(self):
124-
return DeviceConnection.objects.prefetch_related("device")
122+
return (
123+
super()
124+
.get_queryset()
125+
.filter(device_id=self.kwargs["device_id"])
126+
.order_by("-created")
127+
)
125128

126129
def get_serializer_context(self):
127130
context = super().get_serializer_context()
128131
context["device_id"] = self.kwargs["device_id"]
129132
return context
130133

131-
def initial(self, *args, **kwargs):
132-
super().initial(*args, **kwargs)
133-
self.assert_parent_exists()
134-
135-
def assert_parent_exists(self):
136-
try:
137-
assert self.get_parent_queryset().exists()
138-
except (AssertionError, ValidationError):
139-
device_id = self.kwargs["device_id"]
140-
raise NotFound(detail=f'Device with ID "{device_id}" not found.')
141-
142134
def get_parent_queryset(self):
143135
return Device.objects.filter(pk=self.kwargs["device_id"])
144136

145137

146138
class DeviceConnenctionListCreateView(BaseDeviceConnection, ListCreateAPIView):
147139
pagination_class = ListViewPagination
148140

149-
def get_queryset(self):
150-
return (
151-
super()
152-
.get_queryset()
153-
.filter(device_id=self.kwargs["device_id"])
154-
.order_by("-created")
155-
)
156-
157141

158142
class DeviceConnectionDetailView(BaseDeviceConnection, RetrieveUpdateDestroyAPIView):
159143
def get_object(self):

openwisp_controller/connection/tests/test_api.py

Lines changed: 192 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -315,35 +315,72 @@ def test_endpoints_for_deactivated_device(self):
315315
)
316316
self.assertEqual(response.status_code, 200)
317317

318-
def test_non_superuser(self):
319-
list_url = self._get_path("device_command_list", self.device_id)
320-
command = self._create_command(device_conn=self.device_conn)
321-
device = command.device
318+
def _test_command_endpoints(
319+
self,
320+
list_path,
321+
detail_path,
322+
expected_status,
323+
):
324+
with self.subTest("List operation"):
325+
response = self.client.get(list_path)
326+
self.assertEqual(response.status_code, expected_status["list"])
327+
328+
with self.subTest("Create operation"):
329+
response = self.client.post(
330+
list_path,
331+
data={"type": "custom", "input": {"command": "echo test"}},
332+
content_type="application/json",
333+
)
334+
self.assertEqual(response.status_code, expected_status["create"])
322335

323-
with self.subTest("Test with unauthenticated user"):
324-
self.client.logout()
325-
response = self.client.get(list_url)
326-
self.assertEqual(response.status_code, 401)
336+
with self.subTest("Retrieve operation"):
337+
response = self.client.get(detail_path)
338+
self.assertEqual(response.status_code, expected_status["retrieve"])
327339

328-
with self.subTest("Test with organization member"):
329-
org_user = self._create_org_user(is_admin=True)
330-
org_user.user.groups.add(Group.objects.get(name="Operator"))
331-
self.client.force_login(org_user.user)
332-
self.assertEqual(device.organization, org_user.organization)
340+
def test_endpoints_for_org_operators_own_org(self):
341+
self.client.logout()
342+
operator = self._create_operator(organizations=[self._get_org()])
343+
self.client.force_login(operator)
344+
list_path = self._get_path("device_command_list", self.device_id)
345+
command = self._create_command(device_conn=self.device_conn)
346+
detail_path = self._get_path(
347+
"device_command_details", self.device_id, command.id
348+
)
349+
self._test_command_endpoints(
350+
list_path,
351+
detail_path,
352+
expected_status={"list": 200, "create": 201, "retrieve": 200},
353+
)
333354

334-
response = self.client.get(list_url)
335-
self.assertEqual(response.status_code, 200)
336-
self.assertEqual(response.data["count"], 1)
355+
def test_endpoints_for_org_operator_different_org(self):
356+
org2 = self._create_org(name="org2", slug="org2")
357+
org2_admin = self._create_operator(organizations=[org2])
358+
org1_command = self._create_command(device_conn=self.device_conn)
359+
list_path = self._get_path("device_command_list", self.device_id)
360+
detail_path = self._get_path(
361+
"device_command_details", self.device_id, org1_command.id
362+
)
337363

338-
with self.subTest("Test with org member of different org"):
339-
org2 = self._create_org(name="org2", slug="org2")
340-
org2_user = self._create_user(username="org2user", email="[email protected]")
341-
self._create_org_user(organization=org2, user=org2_user, is_admin=True)
342-
self.client.force_login(org2_user)
343-
org2_user.groups.add(Group.objects.get(name="Operator"))
364+
self.client.logout()
365+
self.client.force_login(org2_admin)
366+
self._test_command_endpoints(
367+
list_path,
368+
detail_path,
369+
expected_status={"list": 404, "create": 404, "retrieve": 404},
370+
)
344371

345-
response = self.client.get(list_url)
346-
self.assertEqual(response.status_code, 404)
372+
def test_unauthenticated_user(self):
373+
list_path = self._get_path("device_command_list", self.device_id)
374+
command = self._create_command(device_conn=self.device_conn)
375+
self.client.logout()
376+
detail_path = self._get_path(
377+
"device_command_details", self.device_id, command.id
378+
)
379+
self._test_command_endpoints(
380+
list_path,
381+
detail_path,
382+
expected_status={"list": 401, "create": 401, "retrieve": 401},
383+
)
347384

348385
def test_non_existent_command(self):
349386
url = self._get_path("device_command_list", self.device_id)
@@ -497,7 +534,7 @@ def test_delete_credential_detail(self):
497534
def test_get_deviceconnection_list(self):
498535
d1 = self._create_device()
499536
path = reverse("connection_api:deviceconnection_list", args=(d1.pk,))
500-
with self.assertNumQueries(3):
537+
with self.assertNumQueries(4):
501538
response = self.client.get(path)
502539
self.assertEqual(response.status_code, 200)
503540
self.assertEqual(response.data["count"], 0)
@@ -554,7 +591,7 @@ def test_get_deviceconnection_detail(self):
554591
dc = self._create_device_connection()
555592
d1 = dc.device.id
556593
path = reverse("connection_api:deviceconnection_detail", args=(d1, dc.pk))
557-
with self.assertNumQueries(4):
594+
with self.assertNumQueries(5):
558595
response = self.client.get(path)
559596
self.assertEqual(response.status_code, 200)
560597

@@ -569,7 +606,7 @@ def test_put_devceconnection_detail(self):
569606
"enabled": False,
570607
"failure_reason": "",
571608
}
572-
with self.assertNumQueries(13):
609+
with self.assertNumQueries(14):
573610
response = self.client.put(path, data, content_type="application/json")
574611
self.assertEqual(response.status_code, 200)
575612
self.assertEqual(
@@ -583,7 +620,7 @@ def test_patch_deviceconnectoin_detail(self):
583620
path = reverse("connection_api:deviceconnection_detail", args=(d1, dc.pk))
584621
self.assertEqual(dc.update_strategy, app_settings.UPDATE_STRATEGIES[0][0])
585622
data = {"update_strategy": app_settings.UPDATE_STRATEGIES[1][0]}
586-
with self.assertNumQueries(12):
623+
with self.assertNumQueries(13):
587624
response = self.client.patch(path, data, content_type="application/json")
588625
self.assertEqual(response.status_code, 200)
589626
self.assertEqual(
@@ -594,7 +631,7 @@ def test_delete_deviceconnection_detail(self):
594631
dc = self._create_device_connection()
595632
d1 = dc.device.id
596633
path = reverse("connection_api:deviceconnection_detail", args=(d1, dc.pk))
597-
with self.assertNumQueries(9):
634+
with self.assertNumQueries(10):
598635
response = self.client.delete(path)
599636
self.assertEqual(response.status_code, 204)
600637

@@ -697,3 +734,129 @@ def test_deactivated_device(self):
697734
detail_api_path,
698735
)
699736
self.assertEqual(response.status_code, 403)
737+
738+
def _test_deviceconnection_endpoints(
739+
self,
740+
device_id,
741+
list_path,
742+
detail_path,
743+
expected_status,
744+
):
745+
with self.subTest("List operation"):
746+
response = self.client.get(list_path)
747+
self.assertEqual(response.status_code, expected_status["list"])
748+
749+
with self.subTest("Create operation"):
750+
response = self.client.post(
751+
list_path,
752+
data={
753+
"credentials": self._get_credentials(name="New Credentials").pk,
754+
"update_strategy": app_settings.UPDATE_STRATEGIES[0][0],
755+
"enabled": True,
756+
"failure_reason": "",
757+
},
758+
content_type="application/json",
759+
)
760+
761+
self.assertEqual(response.status_code, expected_status["create"])
762+
763+
with self.subTest("Retrieve operation"):
764+
response = self.client.get(detail_path)
765+
self.assertEqual(response.status_code, expected_status["retrieve"])
766+
767+
with self.subTest("Update operation"):
768+
response = self.client.put(
769+
detail_path,
770+
{
771+
"credentials": self._get_credentials().pk,
772+
"update_strategy": app_settings.UPDATE_STRATEGIES[1][0],
773+
"enabled": False,
774+
"failure_reason": "",
775+
},
776+
content_type="application/json",
777+
)
778+
self.assertEqual(response.status_code, expected_status["update"])
779+
780+
with self.subTest("Partial update operation"):
781+
response = self.client.patch(
782+
detail_path, {"enabled": False}, content_type="application/json"
783+
)
784+
self.assertEqual(response.status_code, expected_status["patch"])
785+
786+
with self.subTest("Delete operation"):
787+
response = self.client.delete(detail_path)
788+
self.assertEqual(response.status_code, expected_status["delete"])
789+
790+
def test_deviceconnection_endpoints_for_org_operators_own_org(self):
791+
self.client.logout()
792+
operator = self._create_operator(organizations=[self._get_org()])
793+
self.client.force_login(operator)
794+
device = self._create_device()
795+
self._create_config(device=device)
796+
dc = self._create_device_connection(device=device)
797+
list_path = reverse("connection_api:deviceconnection_list", args=(device.pk,))
798+
detail_path = reverse(
799+
"connection_api:deviceconnection_detail", args=(device.pk, dc.pk)
800+
)
801+
self._test_deviceconnection_endpoints(
802+
device.pk,
803+
list_path,
804+
detail_path,
805+
expected_status={
806+
"list": 200,
807+
"create": 201,
808+
"retrieve": 200,
809+
"update": 200,
810+
"patch": 200,
811+
"delete": 204,
812+
},
813+
)
814+
815+
def test_deviceconnection_endpoints_for_org_operator_different_org(self):
816+
org2 = self._create_org(name="org2", slug="org2")
817+
org2_operator = self._create_operator(organizations=[org2])
818+
device = self._create_device()
819+
self._create_config(device=device)
820+
dc = self._create_device_connection(device=device)
821+
list_path = reverse("connection_api:deviceconnection_list", args=(device.pk,))
822+
detail_path = reverse(
823+
"connection_api:deviceconnection_detail", args=(device.pk, dc.pk)
824+
)
825+
self.client.logout()
826+
self.client.force_login(org2_operator)
827+
self._test_deviceconnection_endpoints(
828+
device.pk,
829+
list_path,
830+
detail_path,
831+
expected_status={
832+
"list": 404,
833+
"create": 404,
834+
"retrieve": 404,
835+
"update": 404,
836+
"patch": 404,
837+
"delete": 404,
838+
},
839+
)
840+
841+
def test_deviceconnection_unauthenticated_user(self):
842+
device = self._create_device()
843+
self._create_config(device=device)
844+
dc = self._create_device_connection(device=device)
845+
list_path = reverse("connection_api:deviceconnection_list", args=(device.pk,))
846+
detail_path = reverse(
847+
"connection_api:deviceconnection_detail", args=(device.pk, dc.pk)
848+
)
849+
self.client.logout()
850+
self._test_deviceconnection_endpoints(
851+
device.pk,
852+
list_path,
853+
detail_path,
854+
expected_status={
855+
"list": 401,
856+
"create": 401,
857+
"retrieve": 401,
858+
"update": 401,
859+
"patch": 401,
860+
"delete": 401,
861+
},
862+
)

openwisp_controller/mixins.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from openwisp_users.api.mixins import FilterByOrganizationManaged
1+
from openwisp_users.api.mixins import FilterByOrganizationManaged, FilterByParentManaged
22
from openwisp_users.api.mixins import ProtectedAPIMixin as BaseProtectedAPIMixin
33
from openwisp_users.api.permissions import DjangoModelPermissions, IsOrganizationManager
44

@@ -24,9 +24,7 @@ def has_object_permission(self, request, view, obj):
2424
return self._has_permissions(request, view, perm, obj)
2525

2626

27-
class RelatedDeviceProtectedAPIMixin(
28-
BaseProtectedAPIMixin, FilterByOrganizationManaged
29-
):
27+
class RelatedDeviceProtectedAPIMixin(FilterByParentManaged, BaseProtectedAPIMixin):
3028
permission_classes = [
3129
IsOrganizationManager,
3230
RelatedDeviceModelPermission,

0 commit comments

Comments
 (0)