-
-
Notifications
You must be signed in to change notification settings - Fork 71
[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
base: master
Are you sure you want to change the base?
Changes from 3 commits
18775b0
761bdd2
2824f49
30c8aef
5deae9a
3543a8a
e47bdcf
2ec2d7f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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 | ||
|
||
|
@@ -45,10 +46,31 @@ class Meta: | |
|
||
|
||
class FirmwareImageSerializer(BaseSerializer): | ||
file = serializers.FileField(required=False) | ||
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 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Since we are already overriding the value of |
||
|
||
class Meta(BaseMeta): | ||
model = FirmwareImage | ||
fields = '__all__' | ||
|
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 | ||
|
@@ -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 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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() |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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): | ||
|
@@ -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) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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) | ||
pandafy marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
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): | ||
|
@@ -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)]) | ||
|
||
|
@@ -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) | ||
|
||
|
@@ -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) | ||
|
||
|
@@ -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"): | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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') | ||
|
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): | ||||||||||||||||
|
@@ -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) | ||||||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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? openwisp-firmware-upgrader/openwisp_firmware_upgrader/migrations/__init__.py Lines 28 to 34 in ee878d5
|
||||||||||||||||
|
||||||||||||||||
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): | ||||||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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? There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||||||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
|
||||||||||||||||
|
||||||||||||||||
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) |
There was a problem hiding this comment.
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.