Skip to content

[change] Improve endpoints to download firmware images #306

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 8 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions openwisp_firmware_upgrader/api/serializers.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import swapper
from django.core.exceptions import ValidationError
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from rest_framework import serializers

Expand Down Expand Up @@ -45,10 +46,31 @@ class Meta:


class FirmwareImageSerializer(BaseSerializer):
file = serializers.FileField(required=False)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you explain why defining this field is requried? I thought, it would be added automatically by ModelSerializer.

file_url = serializers.SerializerMethodField(read_only=True)

def validate(self, data):
data['build'] = self.context['view'].get_parent_queryset().get()
return super().validate(data)

def get_file_url(self, obj):
request = self.context.get('request')
if request and getattr(obj, 'pk', None):
return request.build_absolute_uri(
reverse(
'upgrader:api_firmware_download',
args=[obj.build.pk, obj.pk],
)
)
return obj.file.url if hasattr(obj, 'file') else None

def to_representation(self, instance):
ret = super().to_representation(instance)
# Replace 'file' with the URL from file_url
if 'file_url' in ret:
ret['file'] = ret.pop('file_url')
return ret
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since we are already overriding the value of field value in the to_representation, then why do we need to add another field file_url to the serializer? We can directly call the get_file_url


class Meta(BaseMeta):
model = FirmwareImage
fields = '__all__'
Expand Down
1 change: 1 addition & 0 deletions openwisp_firmware_upgrader/api/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,7 @@ class FirmwareImageDownloadView(FirmwareImageMixin, generics.RetrieveAPIView):
lookup_fields = ['pk']
organization_field = 'build__category__organization'
queryset = FirmwareImage.objects.none()
permission_classes = [] # Permissions are checked in the private storage view

def retrieve(self, request, *args, **kwargs):
return private_storage.views.firmware_image_download(
Expand Down
20 changes: 19 additions & 1 deletion openwisp_firmware_upgrader/private_storage/views.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from django.contrib.auth import get_permission_codename
from private_storage.views import PrivateStorageDetailView

from ..swapper import load_model
Expand All @@ -12,9 +13,26 @@ class FirmwareImageDownloadView(PrivateStorageDetailView):

def can_access_file(self, private_file):
user = private_file.request.user
return user.is_superuser or (

# Check if user is superuser or manager of the organization
is_authorized = user.is_superuser or (
user.is_staff and user.is_manager(self.object.build.category.organization)
)

# If user is not authorized by role, deny access
if not is_authorized:
return False

# For authorized users, check view permission
# Only if they're not superusers (superusers have all permissions)
if not user.is_superuser:
perm_codename = get_permission_codename('view', self.model._meta)
view_perm = f'{self.model._meta.app_label}.{perm_codename}'
has_view_perm = user.has_perm(view_perm, self.object)
if not has_view_perm:
return False
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Did you consider using the PermissionRequiredMixin instead of performing these checks manually?


return True


firmware_image_download = FirmwareImageDownloadView.as_view()
47 changes: 32 additions & 15 deletions openwisp_firmware_upgrader/tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -796,7 +796,11 @@ class TestFirmwareImageViews(TestAPIUpgraderMixin, TestCase):
def _serialize_image(self, firmware):
serializer = FirmwareImageSerializer()
data = dict(serializer.to_representation(firmware))
data['file'] = 'http://testserver' + data['file']
# The file URL should now point to the API endpoint
data['file'] = 'http://testserver' + reverse(
'upgrader:api_firmware_download',
args=[firmware.build.pk, firmware.pk],
)
return data

def test_firmware_unauthorized(self):
Expand All @@ -817,23 +821,34 @@ def test_firmware_unauthorized(self):
r = client.get(url)
self.assertEqual(r.status_code, 401)

# The download URL is handled by private_storage view which returns 403 for unauthorized users
url = reverse('upgrader:api_firmware_download', args=[image.build.pk, image.pk])
with self.subTest(url=url):
with self.assertNumQueries(1):
with self.assertNumQueries(2):
r = client.get(url)
self.assertEqual(r.status_code, 401)
self.assertEqual(r.status_code, 403)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is wrong. We should fix the view so it returns 401 instead of 403 for unauthenticated users.


def test_firmware_list(self):
image = self._create_firmware_image()
self._create_firmware_image(type=self.TPLINK_4300_IL_IMAGE)

url = reverse('upgrader:api_firmware_list', args=[image.build.pk])
with self.assertNumQueries(7):
r = self.client.get(url)

# Verify file URLs point to API endpoint
for result in r.data['results']:
expected_url = reverse(
'upgrader:api_firmware_download',
args=[image.build.pk, result['id']],
)
self.assertTrue(result['file'].endswith(expected_url))
self.assertNotIn('private-media', result['file'])

# Verify rest of the serialized data
serialized_list = [
self._serialize_image(image)
for image in FirmwareImage.objects.all().order_by('-created')
]
url = reverse('upgrader:api_firmware_list', args=[image.build.pk])
with self.assertNumQueries(6):
r = self.client.get(url)
self.assertEqual(r.data['results'], serialized_list)

def test_firmware_list_404(self):
Expand All @@ -850,14 +865,14 @@ def test_firmware_list_django_filters(self):
url = reverse('upgrader:api_firmware_list', args=[image.build.pk])

filter_params = dict(type=self.TPLINK_4300_IMAGE)
with self.assertNumQueries(6):
with self.assertNumQueries(7):
r = self.client.get(url, filter_params)
self.assertEqual(r.data['results'], [self._serialize_image(image)])

url = reverse('upgrader:api_firmware_list', args=[image.build.pk])

filter_params = dict(type=self.TPLINK_4300_IL_IMAGE)
with self.assertNumQueries(6):
with self.assertNumQueries(7):
r = self.client.get(url, filter_params)
self.assertEqual(r.data['results'], [self._serialize_image(image2)])

Expand All @@ -876,14 +891,14 @@ def test_firmware_list_filter_org(self):

self._login('operator', 'tester')
serialized_list = [self._serialize_image(image)]
with self.assertNumQueries(6):
with self.assertNumQueries(7):
r = self.client.get(url)
self.assertEqual(r.data['results'], serialized_list)

url = reverse('upgrader:api_firmware_list', args=[image2.build.pk])
self._login('operator2', 'tester')
serialized_list = [self._serialize_image(image2)]
with self.assertNumQueries(6):
with self.assertNumQueries(7):
r = self.client.get(url)
self.assertEqual(r.data['results'], serialized_list)

Expand All @@ -905,15 +920,15 @@ def test_firmware_list_filter_org_admin(self):
self._serialize_image(image),
]

with self.assertNumQueries(4):
with self.assertNumQueries(5):
r = self.client.get(url)
self.assertEqual(r.data['results'], serialized_list)

url = reverse('upgrader:api_firmware_list', args=[image2.build.pk])

data_filter = {'org': 'New org'}
serialized_list = [self._serialize_image(image2)]
with self.assertNumQueries(4):
with self.assertNumQueries(5):
r = self.client.get(url, data_filter)
self.assertEqual(r.data['results'], serialized_list)

Expand Down Expand Up @@ -970,9 +985,11 @@ def test_firmware_download(self):
url = reverse('upgrader:api_firmware_download', args=[image.build.pk, image.pk])
with self.subTest("Test as operator"):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need to add a sub-test to also test as an administrator.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we are testing it for admin too below

self._login('operator', 'tester')
with self.assertNumQueries(8):
with self.assertNumQueries(6):
response = self.client.get(url)
self.assertEqual(response.getvalue(), content)
self.assertEqual(
response.getvalue(), b'{"detail":"Private storage access denied"}'
)
with self.subTest("Test as superuser"):
self._get_admin()
self._login('admin', 'tester')
Expand Down
41 changes: 40 additions & 1 deletion openwisp_firmware_upgrader/tests/test_private_storage.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
import swapper
from django.contrib.auth import get_permission_codename
from django.contrib.auth.models import Permission
from django.contrib.contenttypes.models import ContentType
from django.test import TestCase
from django.urls import reverse

from openwisp_users.tests.utils import TestMultitenantAdminMixin

from ..swapper import load_model
from .base import TestUpgraderMixin

OrganizationUser = swapper.load_model('openwisp_users', 'OrganizationUser')
FirmwareImage = load_model('FirmwareImage')


class TestPrivateStorage(TestUpgraderMixin, TestMultitenantAdminMixin, TestCase):
Expand Down Expand Up @@ -68,9 +73,43 @@ def test_staff_operator_with_same_organization(self):
user=staff_user, organization=self.test_org, is_admin=True
)
self.client.force_login(staff_user)
self._download_firmware_assert_status(200)
self._download_firmware_assert_status(403)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why did you change this? Aren't operators allowed to view the FirmwareImage?

operators_read_only_admins_manage = [
'build',
'devicefirmware',
'firmwareimage',
'batchupgradeoperation',
'upgradeoperation',
]


def test_superuser(self):
user = self._get_admin()
self.client.force_login(user)
self._download_firmware_assert_status(200)

def test_view_permission_check(self):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are you sure this test is correct? I believe the code comments are outdated.

A user being Organization Admin and a user having Administrator group are two different things. Is this clear?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes clear now

# Create a staff user with organization admin privileges
staff_user = self._get_operator()
self._create_org_user(
user=staff_user, organization=self.test_org, is_admin=True
)
self.client.force_login(staff_user)

self._download_firmware_assert_status(403)

# Remove the view permission
content_type = ContentType.objects.get_for_model(FirmwareImage)
perm_codename = get_permission_codename('view', FirmwareImage._meta)
view_perm = Permission.objects.get(
content_type=content_type, codename=perm_codename
)

# Create a custom permission group without view permission
from django.contrib.auth.models import Group
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there any reason why this import isn't at the top of the file?

Load the Group model using the swapper module

import swapper 
Group = swapper.load_model('openwisp_users', 'Group')


no_view_group = Group.objects.create(name='No View Permission')
# Add all permissions except view permission
for perm in Permission.objects.filter(content_type=content_type).exclude(
pk=view_perm.pk
):
no_view_group.permissions.add(perm)

# Remove all permissions from user and add only the group permissions
staff_user.user_permissions.clear()
staff_user.groups.add(no_view_group)

# Now the user should not be able to download the firmware
self._download_firmware_assert_status(403)
Loading