Repository: fedora-infra/bodhi

Issue:
sidetag user experience can be improved
It seems that only a creator of a sidetag can submit it as a bodhi request, however, there is no way to assign more than one person to a sidetag management. As result, if sidetag creator does not possess permissions to _all_ components built in the sidetag, nobody will be able to submit the resulting request to bodhi.

I encounter this twice already when doing complex updates of packages with dependent ABI changes.

I'd sugges several changes, not necessarily all falling into bodhi alone:
- allow side-tag creator to add more users to manage the sidetag. Right now they cannot edit sidetag at all, they cannot do anything with it
- allow proven packagers to submit sidetags they do not own
- if a packager has rights to all packages built in sidetag, allow them to submit the sidetag to bodhi

It should not matter who created the sidetag, only a content of the tag should influence who can submit it.


Discussion:
@abbra I forgot to ask that on IRC, did you attempt to create the update using the `bodhi` CLI tool, too? I ask because the code looks as if this is only an issue in the web UI, it only offers side tags created by the logged in user (otherwise the list would be pretty long).

@ryanlerch What do you think, if my assumption is true, should we offer an additional way to enter a side tag manually so people could create updates from side tags they don't own?
@nphilipp I haven't tried the CLI tool. Next time let me not forget about it ;)

Resolving patch:
diff --git a/bodhi/server/services/updates.py b/bodhi/server/services/updates.py
--- a/bodhi/server/services/updates.py
+++ b/bodhi/server/services/updates.py
@@ -492,6 +492,7 @@ def new_update(request):
 
     # Same here, but it can be missing.
     data.pop('builds_from_tag', None)
+    data.pop('sidetag_owner', None)
 
     build_nvrs = data.get('builds', [])
     from_tag = data.get('from_tag')
diff --git a/bodhi/server/validators.py b/bodhi/server/validators.py
--- a/bodhi/server/validators.py
+++ b/bodhi/server/validators.py
@@ -396,6 +396,8 @@ def validate_acls(request, **kwargs):
         request.errors.add('cookies', 'user', 'No ACLs for anonymous user')
         return
     user = User.get(request.user.name)
+    user_groups = [group.name for group in user.groups]
+    acl_system = config.get('acl_system')
     committers = []
     groups = []
 
@@ -408,11 +410,10 @@ def validate_acls(request, **kwargs):
     # (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.
-
-    # This can get confusing.
-    # TODO -- we should break these two roles out into two different clearly
-    # defined validator functions like `validate_acls_for_builds` and
-    # `validate_acls_for_update`.
+    #
+    # 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
     if 'builds' in request.validated:
@@ -427,6 +428,43 @@ def validate_acls(request, **kwargs):
                            'unable to determine ACLs.')
         return
 
+    # Allow certain groups to push updates for any package
+    admin_groups = config['admin_packager_groups']
+    for group in admin_groups:
+        if group in user_groups:
+            log.debug(f'{user.name} is in {group} admin group')
+            return
+
+    # Make sure the user is in the mandatory packager groups. This is a
+    # safeguard in the event a user has commit access on the ACL system
+    # but isn't part of the mandatory groups.
+    mandatory_groups = config['mandatory_packager_groups']
+    for mandatory_group in mandatory_groups:
+        if mandatory_group not in user_groups:
+            error = (f'{user.name} is not a member of "{mandatory_group}", which is a '
+                     f'mandatory packager group')
+            request.errors.add('body', 'builds', error)
+            return
+
+    # If we try to create or edit a side-tag update, check if user owns the side-tag
+    if request.validated.get('from_tag') is not None:
+        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)
+        if sidetag_owner is None:
+            error = ('Update appear to be from side-tag, but we cannot determine '
+                     'the side-tag owner')
+            request.errors.add('body', 'builds', error)
+            return
+
+        if sidetag_owner != user.name:
+            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
     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
@@ -465,34 +503,7 @@ def validate_acls(request, **kwargs):
 
         # Now that we know the release and the package associated with this
         # build, we can ask our ACL system about it..
-
-        acl_system = config.get('acl_system')
-        user_groups = [group.name for group in user.groups]
         has_access = False
-
-        # Allow certain groups to push updates for any package
-        admin_groups = config['admin_packager_groups']
-        for group in admin_groups:
-            if group in user_groups:
-                log.debug('{} is in {} admin group'.format(user.name, group))
-                has_access = True
-                break
-
-        if has_access:
-            continue
-
-        # Make sure the user is in the mandatory packager groups. This is a
-        # safeguard in the event a user has commit access on the ACL system
-        # but isn't part of the mandatory groups.
-        mandatory_groups = config['mandatory_packager_groups']
-        for mandatory_group in mandatory_groups:
-            if mandatory_group not in user_groups:
-                error = ('{0} is not a member of "{1}", which is a '
-                         'mandatory packager group').format(
-                    user.name, mandatory_group)
-                request.errors.add('body', 'builds', error)
-                return
-
         if acl_system == 'pagure':
             try:
                 committers, groups = package.get_pkg_committers_from_pagure()
@@ -527,8 +538,7 @@ def validate_acls(request, **kwargs):
             # Check if this user is in a group that has access to this package
             for group in user_groups:
                 if group in groups:
-                    log.debug('{} is in {} group for {}'.format(
-                        user.name, group, package.name))
+                    log.debug(f'{user.name} is in {group} group for {package.name}')
                     has_access = True
                     break
 
@@ -1357,17 +1367,15 @@ def validate_from_tag(request: pyramid.request.Request, **kwargs: dict):
             request.errors.add('body', 'from_tag', "The supplied tag is not a side tag.")
             return
 
+        # store side-tag owner name to be validated in ACLs
+        request.validated['sidetag_owner'] = taginfo.get('extra', {}).get('sidetag_user', None)
+
         # add all the inherited tags of a sidetag to from_tag_inherited
         for tag in koji_client.getFullInheritance(koji_tag):
             request.from_tag_inherited.append(tag['name'])
 
         if request.validated.get('builds'):
-            # Builds were specified explicitly, verify that the tag exists in Koji.
-            if not taginfo:
-                request.errors.add('body', 'from_tag', "The supplied from_tag doesn't exist.")
-                return
-
-            # Flag that `builds` wasn't filled from the Koji tag.
+            # Builds were specified explicitly, flag that `builds` wasn't filled from the Koji tag.
             request.validated['builds_from_tag'] = False
         else:
             # Builds weren't specified explicitly, pull the list of latest NVRs here, as it is


Test patch:
diff --git a/bodhi/tests/server/services/test_updates.py b/bodhi/tests/server/services/test_updates.py
--- a/bodhi/tests/server/services/test_updates.py
+++ b/bodhi/tests/server/services/test_updates.py
@@ -101,7 +101,7 @@ class TestNewUpdate(BasePyTestCase):
     def mock_getTag(cls, tag, *kwargs):
         if tag == 'f17-build-side-7777':
             return {'maven_support': False, 'locked': False, 'name': 'f17-build-side-7777',
-                    'extra': {'sidetag_user': 'dudemcpants', 'sidetag': True},
+                    'extra': {'sidetag_user': 'guest', 'sidetag': True},
                     'perm': None, 'perm_id': None, 'arches': None, 'maven_include_all': False,
                     'id': 7777}
         return None
@@ -410,6 +410,46 @@ def test_new_rpm_update_from_tag(self, handle_side_and_related_tags_task, rawhid
             "Update already exists using this side tag"
         )
 
+    @mock.patch('bodhi.server.services.updates.handle_side_and_related_tags_task')
+    @mock.patch.dict('bodhi.server.validators.config', {'acl_system': 'dummy'})
+    @unused_mock_patch(**mock_uuid4_version1)
+    @unused_mock_patch(**mock_valid_requirements)
+    @pytest.mark.parametrize('rawhide_workflow', (True, False))
+    def test_new_rpm_update_from_tag_wrong_owner(self, handle_side_and_related_tags_task,
+                                                 rawhide_workflow):
+        """
+        Test creating an update using builds from a Koji tag fails if user doesn't own
+        the side-tag.
+        """
+        # We don't want the new update to obsolete the existing one.
+        self.db.delete(Update.query.one())
+
+        if rawhide_workflow:
+            # We need a release that isn't composed by bodhi
+            release = Release.query.one()
+            release.composed_by_bodhi = False
+            self.db.commit()
+
+        update = self.get_update(builds=None, from_tag='f17-build-side-7777')
+
+        user = User(name='mattia')
+        self.db.add(user)
+        self.db.commit()
+        group = self.db.query(Group).filter_by(name='packager').one()
+        user.groups.append(group)
+
+        with mock.patch('bodhi.server.Session.remove'):
+            app = TestApp(main({}, testing='mattia', session=self.db, **self.app_settings))
+
+        update['csrf_token'] = app.get('/csrf').json_body['csrf_token']
+
+        with mock.patch('bodhi.server.buildsys.DevBuildsys.getTag', self.mock_getTag):
+            with mock.patch('bodhi.server.models.Release.mandatory_days_in_testing', 0):
+                r = app.post_json('/updates/', update, status=403)
+        assert r.json_body['errors'][0]['description'] == (
+            "mattia does not own f17-build-side-7777 side-tag"
+        )
+
     @mock.patch(**mock_valid_requirements)
     def test_koji_config_url(self, *args):
         """
@@ -1259,6 +1299,15 @@ def create_quick_filters_data(self):
         self.create_update([('shiny_new-7.8.9-1.fc40')], 'F40')
         self.db.commit()
 
+    @classmethod
+    def mock_getTag(cls, tag, *kwargs):
+        if tag == 'f17-build-side-7777':
+            return {'maven_support': False, 'locked': False, 'name': 'f17-build-side-7777',
+                    'extra': {'sidetag_user': 'guest', 'sidetag': True},
+                    'perm': None, 'perm_id': None, 'arches': None, 'maven_include_all': False,
+                    'id': 7777}
+        return None
+
     def test_content_type(self):
         """Assert that the content type is displayed in the update template."""
         alias = Update.query.one().alias
@@ -3118,14 +3167,16 @@ def test_edit_rpm_update_from_tag(self, *args):
         self.db.delete(BuildrootOverride.query.one())
 
         update = self.get_update(from_tag='f17-build-side-7777')
-        r = self.app.post_json('/updates/', update)
+        with mock.patch('bodhi.server.buildsys.DevBuildsys.getTag', self.mock_getTag):
+            r = self.app.post_json('/updates/', update)
 
         update['edited'] = r.json['alias']
         update['builds'] = 'bodhi-2.0.0-3.fc17'
         update['requirements'] = 'upgradepath'
 
-        with fml_testing.mock_sends(update_schemas.UpdateEditV1):
-            r = self.app.post_json('/updates/', update)
+        with mock.patch('bodhi.server.buildsys.DevBuildsys.getTag', self.mock_getTag):
+            with fml_testing.mock_sends(update_schemas.UpdateEditV1):
+                r = self.app.post_json('/updates/', update)
 
         up = r.json_body
         assert up['title'] == 'bodhi-2.0.0-3.fc17'
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
@@ -94,7 +94,7 @@ def test_no_requirements(self, mock_taskotron_results):
 class TestValidateAcls(BasePyTestCase):
     """ Test the validate_acls() function.
     """
-    def get_mock_request(self):
+    def get_mock_request(self, sidetag=False):
         """
         A helper function that creates a mock request.
         :return: a Mock object representing a request
@@ -106,7 +106,12 @@ def get_mock_request(self):
         mock_request.user = user
         mock_request.db = self.db
         mock_request.errors = Errors()
-        mock_request.validated = {'update': update}
+        if not sidetag:
+            mock_request.validated = {'update': update}
+        else:
+            mock_request.validated = {'update': update,
+                                      'from_tag': 'f33-build-side-0000',
+                                      'sidetag_owner': user.name}
         mock_request.buildinfo = {'bodhi-2.0-1.fc17': {}}
         return mock_request
 
@@ -303,6 +308,49 @@ def test_validate_acls_invalid_acl_system(self):
         }]
         assert mock_request.errors == error
 
+    @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(self, mock_gpcfp):
+        """Validate acls against sidetag ownership."""
+        mock_request = self.get_mock_request(sidetag=True)
+        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_wrong_owner(self, mock_gpcfp):
+        """Test that a user can submit updates only for sidetags they owns."""
+        mock_request = self.get_mock_request(sidetag=True)
+        mock_request.validated['sidetag_owner'] = 'mattia'
+        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()
+
+    @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_owner_not_set(self, mock_gpcfp):
+        """If side-tag update, sidetag_owner must be present in request."""
+        mock_request = self.get_mock_request(sidetag=True)
+        mock_request.validated['sidetag_owner'] = None
+        validators.validate_acls(mock_request)
+        error = [{
+            'location': 'body',
+            'name': 'builds',
+            'description': ('Update appear to be from side-tag, but we cannot determine '
+                            'the side-tag owner')
+        }]
+        assert mock_request.errors == error
+        mock_gpcfp.assert_not_called()
+
 
 class TestValidateBugFeedback(BasePyTestCase):
     """Test the validate_bug_feedback() function."""
@@ -853,6 +901,7 @@ def test_known_with_builds(self):
         validators.validate_from_tag(self.request)
 
         assert self.request.validated['builds_from_tag'] == False
+        assert self.request.validated['sidetag_owner'] == 'dudemcpants'
         assert not self.request.errors
 
     def test_known_without_builds(self):
@@ -863,6 +912,7 @@ def test_known_without_builds(self):
         validators.validate_from_tag(self.request)
 
         assert self.request.validated['builds_from_tag'] == True
+        assert self.request.validated['sidetag_owner'] == 'dudemcpants'
         assert len(self.request.validated['builds'])
         assert not self.request.errors