User permissions check for Attachment API (#12689)

This commit is contained in:
Oliver
2026-08-23 18:53:33 +10:00
committed by GitHub
parent f16fb36b08
commit 528bb085d7
2 changed files with 112 additions and 0 deletions
+39
View File
@@ -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()
+73
View File
@@ -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."""