Repository: fedora-infra/bodhi

Issue:
Unable to edit update from side-tag: no commit access to one of the builds.
I added a build that I don't have commit access to into [FEDORA-2021-0cad58d4f4](https://bodhi.fedoraproject.org/updates/FEDORA-2021-0cad58d4f4) (f34) and after that I lost permissions to edit the update. I'm the owner of both the side-tag and the bodhi update.

The error message is
```
403 Forbidden
alebastr does not have commit access to cage
alebastr does not have commit access to labwc
```

My guess is that #4133 did not cover permission check for modifying existing update.


Discussion:
The validator is used to check submitted data in backend and to allow a user to display the edit page in frontend. I did not take into account the frontend case, so the edit is currently possible only by directly submitting edits from CLI.
I'll work to fix this ASAP.

Resolving patch:
diff --git a/bodhi/server/validators.py b/bodhi/server/validators.py
--- a/bodhi/server/validators.py
+++ b/bodhi/server/validators.py
@@ -385,7 +385,24 @@ def validate_tags(request, **kwargs):
 @postschema_validator
 def validate_acls(request, **kwargs):
     """
-    Ensure the user has commit privs to these builds or is an admin.
+    Ensure the user has privs to create or edit an update.
+
+    There are two different code-paths that could pass through this validator:
+    one of them is for submitting something new with a list of builds or a
+    side-tag (a new update, or editing an update by changing the list of builds).
+    The other is for changing the request on an existing update -- where an update
+    alias has been passed to us. The latter is also used to check if a user has
+    enough privs to display the edit form page in webUI.
+
+    We need to validate that the user has commit rights on all the build
+    (either the explicitly listed ones or on the ones associated with the
+    pre-existing update). If a provenpackager added a build for which the
+    former update submitter doesn't have commit rights, the original submitter
+    will lose their privs to edit the update.
+
+    In case of a side-tag update, we don't validate against the builds,
+    but we require the submitter to be whom created the side-tag.
+    The 'sidetag_owner' field is set by `validate_from_tag()`.
 
     Args:
         request (pyramid.request.Request): The current request.
@@ -399,21 +416,8 @@ def validate_acls(request, **kwargs):
     user_groups = [group.name for group in user.groups]
     acl_system = config.get('acl_system')
 
-    # There are two different code-paths that could pass through this validator
-    # One of them is for submitting something new with a list of builds (a new
-    # update, or editing an update by changing the list of builds).  The other
-    # is for changing the request on an existing update -- where an update
-    # alias has been passed to us, but not a list of builds.
-    # We need to validate that the user has commit rights on all the build
-    # (either the explicitly listed ones or on the ones associated with the
-    # pre-existing update).. and so we'll do some conditional branching below
-    # to handle those two scenarios.
-    #
-    # In case of a side-tag update, we don't validate against the builds,
-    # but we require the submitter to be who created the side-tag.
-    # The 'sidetag_owner' field is set by `validate_from_tag()`.
-
     builds = None
+    sidetag = None
     if 'builds' in request.validated:
         builds = request.validated['builds']
 
@@ -422,8 +426,9 @@ def validate_acls(request, **kwargs):
             request.errors.add('body', 'update', 'cannot edit Update for an archived Release')
             return
         builds = request.validated['update'].builds
+        sidetag = request.validated['update'].from_tag
 
-    if not builds:
+    if not builds and not sidetag:
         log.warning("validate_acls was passed data with nothing to validate.")
         request.errors.add('body', 'builds', 'ACL validation mechanism was '
                            'unable to determine ACLs.')
@@ -448,7 +453,9 @@ def validate_acls(request, **kwargs):
             return
 
     # If we try to create or edit a side-tag update, check if user owns the side-tag
+    # The 'sidetag_owner' field is set by `validate_from_tag()`.
     if request.validated.get('from_tag') is not None:
+        log.debug('Using side-tag validation method')
         sidetag = request.validated.get('from_tag')
         # the validate_from_tag() must have set the sidetag_owner field
         sidetag_owner = request.validated.get('sidetag_owner', None)
@@ -464,8 +471,21 @@ def validate_acls(request, **kwargs):
                                f'{user.name} does not own {sidetag} side-tag')
             request.errors.status = 403
         return
+    elif 'update' in request.validated and sidetag:
+        # This is a simplified check to avoid quering Koji for the side-tag owner
+        # The user whom created the update is surely the one owning the side-tag
+        update = request.validated['update']
+        if user == update.user:
+            log.debug(f'{user.name} owns {update.alias} side-tag update')
+        else:
+            request.errors.add('body',
+                               'builds',
+                               f'{user.name} does not own {sidetag} side-tag')
+            request.errors.status = 403
+        return
 
     # For normal updates, check against every build
+    log.debug('Using builds validation method')
     for build in builds:
         # The whole point of the blocks inside this conditional is to determine
         # the "release" and "package" associated with the given build.  For raw


Test patch:
diff --git a/bodhi/tests/server/test_validators.py b/bodhi/tests/server/test_validators.py
--- a/bodhi/tests/server/test_validators.py
+++ b/bodhi/tests/server/test_validators.py
@@ -362,6 +362,36 @@ def test_validate_acls_sidetag_owner_not_set(self, mock_gpcfp):
         assert mock_request.errors == error
         mock_gpcfp.assert_not_called()
 
+    @mock.patch('bodhi.server.models.Package.get_pkg_committers_from_pagure',
+                return_value=(['guest'], []))
+    @mock.patch.dict('bodhi.server.validators.config', {'acl_system': 'pagure'})
+    def test_validate_acls_sidetag_update_can_view_edit_page(self, mock_gpcfp):
+        """Test that a user can display the edit form."""
+        mock_request = self.get_mock_request()
+        mock_request.validated['update'].from_tag = 'f33-build-side-0000'
+        validators.validate_acls(mock_request)
+        assert not len(mock_request.errors)
+        mock_gpcfp.assert_not_called()
+
+    @mock.patch('bodhi.server.models.Package.get_pkg_committers_from_pagure',
+                return_value=(['guest'], []))
+    @mock.patch.dict('bodhi.server.validators.config', {'acl_system': 'pagure'})
+    def test_validate_acls_sidetag_update_cannot_view_edit_page(self, mock_gpcfp):
+        """Test that a user can display the edit form."""
+        user = self.db.query(models.User).filter_by(id=2).one()
+        self.db.flush()
+        mock_request = self.get_mock_request()
+        mock_request.validated['update'].from_tag = 'f33-build-side-0000'
+        mock_request.validated['update'].user = user
+        validators.validate_acls(mock_request)
+        error = [{
+            'location': 'body',
+            'name': 'builds',
+            'description': 'guest does not own f33-build-side-0000 side-tag'
+        }]
+        assert mock_request.errors == error
+        mock_gpcfp.assert_not_called()
+
 
 class TestValidateBugFeedback(BasePyTestCase):
     """Test the validate_bug_feedback() function."""