Repository: akvo/akvo-rsr

Issue:
Bug: Enumerators can edit project updates they do not own
### What were you doing?

Logged in as any enumerator of a project, it's possible to edit the project updates made by another user of the project.

### What should've happened?

The enumerator shouldn't have the right to edit any project updates they themselves didn't create.

### My environment

_No response_

### Additional context

_No response_


Discussion:

Resolving patch:
diff --git a/akvo/rsr/models/__init__.py b/akvo/rsr/models/__init__.py
--- a/akvo/rsr/models/__init__.py
+++ b/akvo/rsr/models/__init__.py
@@ -305,8 +305,10 @@
 
 rules.add_perm('rsr.add_projectupdate', is_rsr_admin | is_org_admin | is_org_user_manager |
                is_org_me_manager_or_project_editor | is_org_enumerator | is_org_user | is_own)
-rules.add_perm('rsr.change_projectupdate', is_own | is_rsr_admin | is_org_admin | is_org_user_manager |
+rules.add_perm('rsr.view_projectupdate', is_own | is_rsr_admin | is_org_admin | is_org_user_manager |
                is_org_me_manager_or_project_editor | is_org_user | is_org_enumerator)
+rules.add_perm('rsr.change_projectupdate', is_own | is_rsr_admin | is_org_admin | is_org_user_manager |
+               is_org_me_manager_or_project_editor | (is_own & is_org_user) | (is_own & is_org_enumerator))
 rules.add_perm('rsr.delete_projectupdate', is_own | is_rsr_admin | is_org_admin)
 
 rules.add_perm('rsr.add_projectupdatelocation', is_rsr_admin)
diff --git a/akvo/rsr/project_role_auth.py b/akvo/rsr/project_role_auth.py
--- a/akvo/rsr/project_role_auth.py
+++ b/akvo/rsr/project_role_auth.py
@@ -1,12 +1,17 @@
 from django.contrib.auth.backends import BaseBackend
 from django.core.exceptions import PermissionDenied
-from django.db.models import Q
+from django.db.models import Model, Q
+from rules import Predicate
 from rules.permissions import permissions
 
 from akvo.rsr.models import Project, ProjectRole, User
 from akvo.rsr.permissions import PERM_NAME_GROUP_MAP
 from akvo.utils import get_project_for_object
 
+OWNABLE_MODELS = {
+    "rsr.projectupdate"
+}
+
 
 class ProjectRolePermissionBackend(BaseBackend):
     def authenticate(self, request, **kwargs):
@@ -25,6 +30,9 @@ def has_perm(self, user, perm, obj=None):
             # have the required permission, irrespective of which project the
             # role exists for.
             return all_roles.exists()
+        elif can_own_model(perm, obj) and (obj_user_id := getattr(obj, "user_id", None)):
+            # When a model can be owned despite the project role, check if the current user does own it
+            return obj_user_id and obj.user_id == user.id
 
         project = get_project_for_object(Project, obj)
         if project is None:
@@ -51,6 +59,22 @@ def has_perm(self, user, perm, obj=None):
             raise PermissionDenied
 
 
+def can_own_model(permission: str, obj: Model) -> bool:
+    """
+    A simplistic check whether object ownership is important for the permission check
+
+    Just like the rest of this permission system, this method shouldn't exist.
+    It's extremely simplistic in that it ignores predicate logic and just checks if "is_own"
+     is in the predicate.
+    It would just take too much time to write another hack like User.parse_permission_expression
+    This might go wrong for complicated predicates.
+    """
+    predicate: Predicate = permissions.get(permission, None)
+    if predicate is None:
+        return False
+    return "is_own" in predicate.name and str(obj._meta) in OWNABLE_MODELS
+
+
 def groups_from_permission(permission):
     predicate = permissions.get(permission, None)
     if predicate is None:


Test patch:
diff --git a/akvo/rsr/tests/rest/test_permissions.py b/akvo/rsr/tests/rest/test_permissions.py
--- a/akvo/rsr/tests/rest/test_permissions.py
+++ b/akvo/rsr/tests/rest/test_permissions.py
@@ -29,8 +29,8 @@ def slugify(s):
 group_count = collections.namedtuple('group_count', ('admin', 'anonymous', 'editor', 'other'))
 
 
-class PermissionFilteringTestCase(TestCase):
-    """Tests the filtering of querysets based on user permissions."""
+class CreatePermissionFilteringTestCase(TestCase):
+    """Tests the filtering of querysets for users who would like to create objects"""
 
     @classmethod
     def setUpClass(cls):
@@ -394,7 +394,7 @@ def populate_model_map():
         # 8 project updates group_count(for each user in each group) per project
         # Every non-privileged user will also be able to see their update
         model_map[M.ProjectUpdate] = {
-            'group_count': group_count(64, 16, (48, 48), (48, 34)),
+            'group_count': group_count(64, 16, (48, 48), (34, 34)),
             'project_relation': 'project__'
         }
 
diff --git a/akvo/rsr/tests/rest/test_project_update.py b/akvo/rsr/tests/rest/test_project_update.py
--- a/akvo/rsr/tests/rest/test_project_update.py
+++ b/akvo/rsr/tests/rest/test_project_update.py
@@ -10,9 +10,12 @@
 import json
 
 from os.path import abspath, dirname, join
+
+from django.contrib.auth.models import Group
 from django.core.files.uploadedfile import SimpleUploadedFile
 from django.test.client import BOUNDARY, MULTIPART_CONTENT, encode_multipart
-from akvo.rsr.models import Organisation, Partnership, Project, ProjectUpdate, ProjectUpdatePhoto
+from akvo.rsr.models import Organisation, Partnership, Project, ProjectRole, ProjectUpdate, ProjectUpdatePhoto
+from akvo.rsr.permissions import GROUP_NAME_ENUMERATORS
 from akvo.rsr.tests.base import BaseTestCase
 from akvo.rsr.tests.utils import ProjectFixtureBuilder
 
@@ -200,6 +203,119 @@ def test_rest_post_project_update_photo_none(self):
         self.assertEqual(response.status_code, 201)
 
 
+class RestProjectUpdateEnumeratorTestCase(BaseTestCase):
+    """Tests the project update REST endpoints from the perspective of an enumerator"""
+
+    def setUp(self):
+        """
+        Creates a project update made by an enumerator
+        """
+
+        super().setUp()
+        # Create active enumerator
+        self.enumerator = self.create_user("enumerator@test.akvo.org", "password")
+        self.org = Organisation.objects.create(name='org', long_name='org')
+        self.make_employment(self.enumerator, self.org, GROUP_NAME_ENUMERATORS)
+
+        # Create second enumerator
+        self.enumerator_steve = self.create_user("enumerator_steve@test.akvo.org", "password")
+        self.make_employment(self.enumerator_steve, self.org, GROUP_NAME_ENUMERATORS)
+
+        # Create projects
+        self.project = Project.objects.create(title="REST test project 2")
+        self.project.publish()
+        Partnership.objects.create(organisation=self.org, project=self.project)
+
+        # Create update
+        self.update_enum = ProjectUpdate.objects.create(
+            project=self.project,
+            user=self.enumerator,
+            title="My simple title",
+        )
+        self.update_steve = ProjectUpdate.objects.create(
+            project=self.project,
+            user=self.enumerator_steve,
+            title="My second simple title",
+        )
+
+        self.c.force_login(self.enumerator)
+
+    def test_view_all_updates(self):
+        """Ensure all updates can still be read"""
+
+        response = self.c.get("/rest/v1/project_update/?format=json")
+        self.assertEqual(response.status_code, 200, msg=response.content)
+        data = response.json()
+        self.assertEqual(data["count"], 2)
+
+    def test_own_update(self):
+        """Ensure the owned update has certain properties"""
+
+        response = self.c.get(f"/rest/v1/project_update/{self.update_enum.id}/?format=json")
+        self.assertEqual(response.status_code, 200, msg=response.content)
+        data = response.json()
+        self.assertEqual(data["editable"], True)
+        self.assertEqual(data["deletable"], True)
+
+    def test_other_update(self):
+        """Ensure the update owned by the other enumerator has certain properties"""
+
+        response = self.c.get(f"/rest/v1/project_update/{self.update_steve.id}/?format=json")
+        self.assertEqual(response.status_code, 200, msg=response.content)
+        data = response.json()
+
+        self.assertEqual(data["editable"], False)
+        self.assertEqual(data["deletable"], False)
+
+    def test_fail_modify_updates_by_other_enumerators(self):
+        """Modifying updates made by others should fail"""
+
+        response = self.c.patch(
+            f"/rest/v1/project_update/{self.update_steve.id}/?format=json",
+            json.dumps({"title": "new title"}),
+            content_type="application/json"
+        )
+        self.assertEqual(response.status_code, 403, msg=response.content)
+
+    def test_fail_delete_updates_by_other_enumerators(self):
+        """Deleting updates made by others should fail"""
+        response = self.c.delete(
+            f"/rest/v1/project_update/{self.update_steve.id}/?format=json",
+        )
+        self.assertEqual(response.status_code, 403, msg=response.content)
+
+    def test_modify_own_update(self):
+        """Modifying owned updates should succeed"""
+        response = self.c.patch(
+
+            f"/rest/v1/project_update/{self.update_enum.id}/?format=json",
+            json.dumps({"title": "new title"}),
+            content_type="application/json"
+        )
+        self.assertEqual(response.status_code, 200, msg=response.content)
+
+    def test_delete_own_update(self):
+        """Deleting owned updates should succeed"""
+
+        response = self.c.delete(
+            f"/rest/v1/project_update/{self.update_enum.id}/?format=json",
+        )
+        self.assertEqual(response.status_code, 204, msg=response.content)
+
+
+class RestProjectRoleUpdateEnumeratorTestCase(RestProjectUpdateEnumeratorTestCase):
+    def setUp(self):
+        super().setUp()
+
+        self.project.use_project_roles = True
+        self.project.save(update_fields=['use_project_roles'])
+        ProjectRole.objects.create(
+            project=self.project,
+            user=self.enumerator,
+            group=Group.objects.get(name=GROUP_NAME_ENUMERATORS)
+        )
+
+
 def get_mock_image():
     image_path = join(dirname(dirname(abspath(__file__))), 'iati_export', 'test_image.jpg')
     with open(image_path, 'r+b') as f:
diff --git a/akvo/rsr/tests/test_permissions.py b/akvo/rsr/tests/test_permissions.py
--- a/akvo/rsr/tests/test_permissions.py
+++ b/akvo/rsr/tests/test_permissions.py
@@ -552,10 +552,11 @@ def test_org_enumerator_permissions(self):
         # "M&E manager" actions
         self.assertFalse(user.has_perm('rsr.do_me_manager_actions'))
 
-        # Project Update permissions
+        # Project Update & view permissions
         for i, project_update in enumerate(self.project_updates):
             test = self.assertTrue if i == 0 else self.assertFalse
             test(user.has_perm('rsr.change_projectupdate', project_update))
+            test(user.has_perm('rsr.view_projectupdate', project_update))
 
         # Project permissions
         for i, project in enumerate(self.projects):