diff --git a/src/backend/InvenTree/common/api.py b/src/backend/InvenTree/common/api.py index 489530c51e..792b7dd79f 100644 --- a/src/backend/InvenTree/common/api.py +++ b/src/backend/InvenTree/common/api.py @@ -789,6 +789,26 @@ class AttachmentFilter(FilterSet): tag_name = common.filters.TagsFilter() +def get_viewable_attachment_model_types(user) -> set: + """Return the set of attachment 'model_type' labels the user has 'view' permission for. + + Attachments are a generic table keyed by (model_type, model_id), with no RuleSet + mapping of their own - so read access is based on the *linked* model's own + RuleSet permission instead, mirroring how AttachmentDetail already checks + 'change'/'delete' via Attachment.check_permission() for writes. + """ + from common.validators import attachment_model_types + from users.permissions import check_user_permission, prefetch_rule_sets + + groups = prefetch_rule_sets(user) + + return { + model.__name__.lower() + for model in attachment_model_types() + if check_user_permission(user, model, 'view', groups=groups) + } + + class AttachmentMixin: """Mixin class for Attachment views.""" @@ -806,6 +826,14 @@ class AttachmentList(AttachmentMixin, BulkDeleteMixin, ListCreateAPI): ordering_fields = ['model_id', 'model_type', 'upload_date', 'file_size'] search_fields = ['comment', 'model_id', 'model_type'] + def get_queryset(self): + """Restrict the queryset to attachments linked to a model the user can view.""" + queryset = super().get_queryset() + + allowed_types = get_viewable_attachment_model_types(self.request.user) + + return queryset.filter(model_type__in=allowed_types) + def perform_create(self, serializer): """Save the user information when a file is uploaded.""" serializer.save(upload_user=self.request.user) @@ -832,6 +860,17 @@ class AttachmentList(AttachmentMixin, BulkDeleteMixin, ListCreateAPI): class AttachmentDetail(AttachmentMixin, RetrieveUpdateDestroyAPI): """Detail API endpoint for Attachment objects.""" + def retrieve(self, request, *args, **kwargs): + """Retrieve a single attachment object, if the user has view permission.""" + attachment = self.get_object() + + if not attachment.check_permission('view', request.user): + raise PermissionDenied( + _('User does not have permission to view this attachment') + ) + + return super().retrieve(request, *args, **kwargs) + def update(self, request, *args, **kwargs): """Update an existing attachment object.""" attachment = self.get_object() diff --git a/src/backend/InvenTree/common/test_api.py b/src/backend/InvenTree/common/test_api.py index 038bd02689..c530ac1907 100644 --- a/src/backend/InvenTree/common/test_api.py +++ b/src/backend/InvenTree/common/test_api.py @@ -1062,6 +1062,79 @@ class AttachmentAPITests(InvenTreeAPITestCase): # Ensure that the file associated with each attachment has been removed self.assertFalse(default_storage.exists(att.attachment.path)) + def test_attachment_read_permissions(self): + """Test that reading attachments is gated on the linked model's own view permission. + + A user should not be able to list or retrieve attachments linked to a model + type they have no view permission for, even though attachments themselves + have no RuleSet of their own (see users.ruleset.get_ruleset_ignore). + """ + from common.models import Attachment + from part.models import Part + from stock.models import StockItem + + part = Part.objects.create(name='Attachable Part', description='A part') + item = StockItem.objects.create(part=part, quantity=10) + + part_attachment = Attachment.objects.create( + model_type='part', + model_id=part.pk, + comment='part attachment', + link='https://example.com/part', + ) + stock_attachment = Attachment.objects.create( + model_type='stockitem', + model_id=item.pk, + comment='stock attachment', + link='https://example.com/stock', + ) + + # User has no roles at all - should see nothing, and be denied on direct retrieve + list_url = reverse('api-attachment-list') + response = self.get(list_url, expected_code=200) + result_ids = {result['pk'] for result in response.data} + self.assertNotIn(part_attachment.pk, result_ids) + self.assertNotIn(stock_attachment.pk, result_ids) + + self.get( + reverse('api-attachment-detail', kwargs={'pk': part_attachment.pk}), + expected_code=403, + ) + self.get( + reverse('api-attachment-detail', kwargs={'pk': stock_attachment.pk}), + expected_code=403, + ) + + # Grant 'view' permission on 'part' only + self.assignRole('part.view') + + response = self.get(list_url, expected_code=200) + result_ids = {result['pk'] for result in response.data} + self.assertIn(part_attachment.pk, result_ids) + self.assertNotIn(stock_attachment.pk, result_ids) + + self.get( + reverse('api-attachment-detail', kwargs={'pk': part_attachment.pk}), + expected_code=200, + ) + self.get( + reverse('api-attachment-detail', kwargs={'pk': stock_attachment.pk}), + expected_code=403, + ) + + # Granting 'stock' view permission too now exposes both + self.assignRole('stock.view') + + response = self.get(list_url, expected_code=200) + result_ids = {result['pk'] for result in response.data} + self.assertIn(part_attachment.pk, result_ids) + self.assertIn(stock_attachment.pk, result_ids) + + self.get( + reverse('api-attachment-detail', kwargs={'pk': stock_attachment.pk}), + expected_code=200, + ) + class AttachmentThumbnailAPITests(InvenTreeAPITestCase): """Tests for thumbnail generation when uploading attachments via the API."""