Repository: inventree/InvenTree

Issue:
[BUG] :checkered_flag: Stock item ownership results in stock item being read-only
### Please verify that this bug has NOT been raised before.

- [X] I checked and didn't find similar issue

### Describe the bug*

This is a followup for #4228.

When a user is a member of more than one group, stock items owned by one of these groups are read-only despite the group having VACD rights on stock items.

### Steps to Reproduce

1. Create two groups with VACD rights on stock items.
2. Add a normal (non-admin) user account to both groups.
3. Have a stock item being owned by one of these groups.
4. Notice how the stock item is read-only when logged in as said user.

### UPDATE 2022-01-20 ###

After further investigation it seems stock item ownership is broken in general. Even when the stock item is owned by the current user it appears read-only.

### Expected behavior

The stock item is modifiable by the user.

### Deployment Method

- [ ] Docker
- [X] Bare metal

### Version Information

# Version Information:
InvenTree-Version: 0.10.0 dev
Django Version: 3.2.16
Commit Hash: 28ec6b9d1
Commit Date: 2022-12-25
Database: postgresql
Debug-Mode: False
Deployed using Docker: False
Active plugins: [{'name': 'InvenTreeBarcode', 'slug': 'inventreebarcode', 'version': '2.0.0'}]





Discussion:
@sur5r I actually think you don't even need to belong to two groups for this bug to happen, when I reproduced it my user belonged to one group only but my user did not have write access to stock items owned by his own group...
Can reproduce. @sur5r thanks for reporting it.

In general:
 Fixing this should solely focus on getting documented behaviour up and running again. Permissions are due for a rework as described in #2323. That is a more significant conceptual change that should be discussed on a process level before changing the core mechanism.
@matmair Why wait `0.10.0`? This should go into `0.9.x` for bug fixing, no?
My understanding is that we are rolling 0.10.0 once Oliver is back so I tagged it for 0.10.0 - we can change it to 0.9.x but I do not think it will change anything
I just noticed I even get "read only" on stock items owned by my user directly. Something must be completely off here.
> I just noticed I even get "read only" on stock items owned by my user directly. Something must be completely off here.

I guess that part was broken at some point... mind updating the title and comment of this issue to reflect your findings?
I just bisected on the stable branch and found 9841f478060a09b5611cade36756b93fc596eb1a to be the first bad commit.
Great pointer. Do you want to work on this or should I try to fix it?
As I  haven't really looked into how the permission system works yet I think it's best if you do it.

Resolving patch:
diff --git a/InvenTree/stock/models.py b/InvenTree/stock/models.py
--- a/InvenTree/stock/models.py
+++ b/InvenTree/stock/models.py
@@ -146,7 +146,7 @@ def check_ownership(self, user):
             # So, no ownership checks to perform!
             return True
 
-        return user in owner.get_related_owners(include_group=True)
+        return owner.is_user_allowed(user, include_group=True)
 
     def clean(self):
         """Custom clean action for the StockLocation model:
@@ -864,7 +864,7 @@ def check_ownership(self, user):
         if owner is None:
             return True
 
-        return user in owner.get_related_owners(include_group=True)
+        return owner.is_user_allowed(user, include_group=True)
 
     def is_stale(self):
         """Returns True if this Stock item is "stale".
diff --git a/InvenTree/users/models.py b/InvenTree/users/models.py
--- a/InvenTree/users/models.py
+++ b/InvenTree/users/models.py
@@ -639,9 +639,9 @@ def get_owner(cls, user_or_group):
                                 ContentType.objects.get_for_model(user_model).id]
 
         # If instance type is obvious: set content type
-        if type(user_or_group) is Group:
+        if isinstance(user_or_group, Group):
             content_type_id = content_type_id_list[0]
-        elif type(user_or_group) is get_user_model():
+        elif isinstance(user_or_group, get_user_model()):
             content_type_id = content_type_id_list[1]
 
         if content_type_id:
@@ -678,6 +678,12 @@ def get_related_owners(self, include_group=False):
 
         return related_owners
 
+    def is_user_allowed(self, user, include_group: bool = False):
+        """Check if user is allowed to access something owned by this owner."""
+
+        user_owner = Owner.get_owner(user)
+        return user_owner in self.get_related_owners(include_group=include_group)
+
 
 @receiver(post_save, sender=Group, dispatch_uid='create_owner')
 @receiver(post_save, sender=get_user_model(), dispatch_uid='create_owner')


Test patch:
diff --git a/InvenTree/stock/test_views.py b/InvenTree/stock/test_views.py
--- a/InvenTree/stock/test_views.py
+++ b/InvenTree/stock/test_views.py
@@ -1,10 +1,13 @@
 """Unit tests for Stock views (see views.py)."""
 
+from django.contrib.auth.models import Group
 from django.urls import reverse
 
+from common.models import InvenTreeSetting
 from InvenTree.helpers import InvenTreeTestCase
-
-# from common.models import InvenTreeSetting
+from InvenTree.status_codes import StockStatus
+from stock.models import StockItem, StockLocation
+from users.models import Owner
 
 
 class StockViewTestCase(InvenTreeTestCase):
@@ -84,124 +87,85 @@ def test_basic_info(self):
 
 class StockOwnershipTest(StockViewTestCase):
     """Tests for stock ownership views."""
-
-    def setUp(self):
-        """Add another user for ownership tests."""
-
-    """
-    TODO: Refactor this following test to use the new API form
-
-        super().setUp()
-
-        # Promote existing user with staff, admin and superuser statuses
-        self.user.is_staff = True
-        self.user.is_admin = True
-        self.user.is_superuser = True
-        self.user.save()
-
-        # Create a new user
-        user = get_user_model()
-
-        self.new_user = user.objects.create_user(
-            username='john',
-            email='john@email.com',
-            password='custom123',
-        )
-
-        # Put the user into a new group with the correct permissions
-        group = Group.objects.create(name='new_group')
-        self.new_user.groups.add(group)
-
-        # Give the group *all* the permissions!
-        for rule in group.rule_sets.all():
-            rule.can_view = True
-            rule.can_change = True
-            rule.can_add = True
-            rule.can_delete = True
-
-            rule.save()
+    test_item_id = 11
+    test_location_id = 1
 
     def enable_ownership(self):
+        """Helper function to turn on ownership control."""
         # Enable stock location ownership
 
         InvenTreeSetting.set_setting('STOCK_OWNERSHIP_CONTROL', True, self.user)
         self.assertEqual(True, InvenTreeSetting.get_setting('STOCK_OWNERSHIP_CONTROL'))
 
-    def test_owner_control(self):
-        # Test stock location and item ownership
-        from .models import StockLocation
-        from users.models import Owner
+    def assert_ownership(self, assertio: bool = True, user=None):
+        """Helper function to check ownership control."""
+        if user is None:
+            user = self.user
+
+        item = StockItem.objects.get(pk=self.test_item_id)
+        self.assertEqual(assertio, item.check_ownership(user))
+
+        location = StockLocation.objects.get(pk=self.test_location_id)
+        self.assertEqual(assertio, location.check_ownership(user))
 
-        new_user_group = self.new_user.groups.all()[0]
-        new_user_group_owner = Owner.get_owner(new_user_group)
+    def assert_api_change(self):
+        """Helper function to get response to API change."""
+        return self.client.patch(
+            reverse('api-stock-detail', args=(self.test_item_id,)),
+            {'status': StockStatus.DAMAGED},
+            content_type='application/json',
+        )
 
-        user_as_owner = Owner.get_owner(self.user)
-        new_user_as_owner = Owner.get_owner(self.new_user)
+    def test_owner_no_ownership(self):
+        """Check without ownership control enabled. Should always return True."""
+        self.assert_ownership(True)
 
-        # Enable ownership control
+    def test_ownership_as_superuser(self):
+        """Test that superuser are always allowed to access items."""
         self.enable_ownership()
 
-        test_location_id = 4
-        test_item_id = 11
-        # Set ownership on existing item (and change location)
-        response = self.client.post(reverse('stock-item-edit', args=(test_item_id,)),
-                                    {'part': 1, 'status': StockStatus.OK, 'owner': user_as_owner.pk},
-                                    HTTP_X_REQUESTED_WITH='XMLHttpRequest')
-
-        self.assertContains(response, '"form_valid": true', status_code=200)
-
-
-        # Logout
-        self.client.logout()
-
-        # Login with new user
-        self.client.login(username='john', password='custom123')
-
-        # TODO: Refactor this following test to use the new API form
-        # Test item edit
-        response = self.client.post(reverse('stock-item-edit', args=(test_item_id,)),
-                                    {'part': 1, 'status': StockStatus.OK, 'owner': new_user_as_owner.pk},
-                                    HTTP_X_REQUESTED_WITH='XMLHttpRequest')
-
-        # Make sure the item's owner is unchanged
-        item = StockItem.objects.get(pk=test_item_id)
-        self.assertEqual(item.owner, user_as_owner)
-
-        # Create new parent location
-        parent_location = {
-            'name': 'John Desk',
-            'description': 'John\'s desk',
-            'owner': new_user_group_owner.pk,
-        }
-
-        # Retrieve created location
-        location_created = StockLocation.objects.get(name=new_location['name'])
-
-        # Create new item
-        new_item = {
-            'part': 25,
-            'location': location_created.pk,
-            'quantity': 123,
-            'status': StockStatus.OK,
-        }
-
-        # Try to create new item with no owner
-        response = self.client.post(reverse('stock-item-create'),
-                                    new_item, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
-        self.assertContains(response, '"form_valid": false', status_code=200)
-
-        # Try to create new item with invalid owner
-        new_item['owner'] = user_as_owner.pk
-        response = self.client.post(reverse('stock-item-create'),
-                                    new_item, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
-        self.assertContains(response, '"form_valid": false', status_code=200)
-
-        # Try to create new item with valid owner
-        new_item['owner'] = new_user_as_owner.pk
-        response = self.client.post(reverse('stock-item-create'),
-                                    new_item, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
-        self.assertContains(response, '"form_valid": true', status_code=200)
-
-        # Logout
-        self.client.logout()
-    """
+        # Check with superuser
+        self.user.is_superuser = True
+        self.user.save()
+        self.assert_ownership(True)
+
+    def test_ownership_functions(self):
+        """Test that ownership is working correctly for StockItem/StockLocation."""
+        self.enable_ownership()
+        item = StockItem.objects.get(pk=self.test_item_id)
+        location = StockLocation.objects.get(pk=self.test_location_id)
+
+        # Check that user is not allowed to change item
+        self.assertTrue(item.check_ownership(self.user))        # No owner -> True
+        self.assertTrue(location.check_ownership(self.user))    # No owner -> True
+        self.assertContains(self.assert_api_change(), 'You do not have permission to perform this action.', status_code=403)
+
+        # Adjust group rules
+        group = Group.objects.get(name='my_test_group')
+        rule = group.rule_sets.get(name='stock')
+        rule.can_change = True
+        rule.save()
+
+        # Set owner to group of user
+        group_owner = Owner.get_owner(group)
+        item.owner = group_owner
+        item.save()
+        location.owner = group_owner
+        location.save()
+
+        # Check that user is allowed to change item
+        self.assertTrue(item.check_ownership(self.user))        # Owner is group -> True
+        self.assertTrue(location.check_ownership(self.user))    # Owner is group -> True
+        self.assertContains(self.assert_api_change(), f'"status":{StockStatus.DAMAGED}', status_code=200)
+
+        # Change group
+        new_group = Group.objects.create(name='new_group')
+        new_group_owner = Owner.get_owner(new_group)
+        item.owner = new_group_owner
+        item.save()
+        location.owner = new_group_owner
+        location.save()
+
+        # Check that user is not allowed to change item
+        self.assertFalse(item.check_ownership(self.user))       # Owner is not in group -> False
+        self.assertFalse(location.check_ownership(self.user))    # Owner is not in group -> False