mirror of
https://github.com/inventree/InvenTree.git
synced 2026-08-31 17:28:07 +00:00
User permissions check for Attachment API (#12689)
This commit is contained in:
@@ -789,6 +789,26 @@ class AttachmentFilter(FilterSet):
|
|||||||
tag_name = common.filters.TagsFilter()
|
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:
|
class AttachmentMixin:
|
||||||
"""Mixin class for Attachment views."""
|
"""Mixin class for Attachment views."""
|
||||||
|
|
||||||
@@ -806,6 +826,14 @@ class AttachmentList(AttachmentMixin, BulkDeleteMixin, ListCreateAPI):
|
|||||||
ordering_fields = ['model_id', 'model_type', 'upload_date', 'file_size']
|
ordering_fields = ['model_id', 'model_type', 'upload_date', 'file_size']
|
||||||
search_fields = ['comment', 'model_id', 'model_type']
|
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):
|
def perform_create(self, serializer):
|
||||||
"""Save the user information when a file is uploaded."""
|
"""Save the user information when a file is uploaded."""
|
||||||
serializer.save(upload_user=self.request.user)
|
serializer.save(upload_user=self.request.user)
|
||||||
@@ -832,6 +860,17 @@ class AttachmentList(AttachmentMixin, BulkDeleteMixin, ListCreateAPI):
|
|||||||
class AttachmentDetail(AttachmentMixin, RetrieveUpdateDestroyAPI):
|
class AttachmentDetail(AttachmentMixin, RetrieveUpdateDestroyAPI):
|
||||||
"""Detail API endpoint for Attachment objects."""
|
"""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):
|
def update(self, request, *args, **kwargs):
|
||||||
"""Update an existing attachment object."""
|
"""Update an existing attachment object."""
|
||||||
attachment = self.get_object()
|
attachment = self.get_object()
|
||||||
|
|||||||
@@ -1062,6 +1062,79 @@ class AttachmentAPITests(InvenTreeAPITestCase):
|
|||||||
# Ensure that the file associated with each attachment has been removed
|
# Ensure that the file associated with each attachment has been removed
|
||||||
self.assertFalse(default_storage.exists(att.attachment.path))
|
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):
|
class AttachmentThumbnailAPITests(InvenTreeAPITestCase):
|
||||||
"""Tests for thumbnail generation when uploading attachments via the API."""
|
"""Tests for thumbnail generation when uploading attachments via the API."""
|
||||||
|
|||||||
Reference in New Issue
Block a user