From 76352587fabaf84036e448c75e358975c62b3311 Mon Sep 17 00:00:00 2001
From: Tomas Kopecek <tkopecek@redhat.com>
Date: Apr 08 2020 10:48:14 +0000
Subject: [PATCH 1/9] editSideTag API call


New API call for editing basic info on sidetags. Needs to be applied
with proper policies.

Fixes: https://pagure.io/koji/issue/1998

---

diff --git a/hub/kojihub.py b/hub/kojihub.py
index 5932415..6b32e31 100644
--- a/hub/kojihub.py
+++ b/hub/kojihub.py
@@ -990,8 +990,11 @@ def _direct_pkglist_add(taginfo, pkginfo, owner, block, extra_arches, force,
         action = 'block'
     if policy:
         context.session.assertLogin()
-        policy_data = {'tag': tag_id, 'action': action, 'package': pkginfo, 'force': force}
-        assert_policy('package_list', policy_data, force=force)
+        policy_data = {'tag': tag_id, 'action': action, 'package': pkginfo,
+                       'force' : force, 'extra': tag['extra']}
+        # don't check policy for admins using force
+        if not (force and context.session.hasPerm('admin')):
+            assert_policy('package_list', policy_data)
     if not pkg:
         pkg = lookup_package(pkginfo, create=True)
     # validate arches before running callbacks
@@ -1068,7 +1071,8 @@ def _direct_pkglist_remove(taginfo, pkginfo, force=False, policy=False):
     pkg = lookup_package(pkginfo, strict=True)
     if policy:
         context.session.assertLogin()
-        policy_data = {'tag': tag['id'], 'action': 'remove', 'package': pkg['id'], 'force': force}
+        policy_data = {'tag': tag['id'], 'action': 'remove', 'package': pkg['id'],
+                       'force' : force, 'extra': tag['extra']}
         # don't check policy for admins using force
         assert_policy('package_list', policy_data, force=force)
 
@@ -1100,7 +1104,8 @@ def pkglist_unblock(taginfo, pkginfo, force=False):
     tag = get_tag(taginfo, strict=True)
     pkg = lookup_package(pkginfo, strict=True)
     context.session.assertLogin()
-    policy_data = {'tag': tag['id'], 'action': 'unblock', 'package': pkg['id'], 'force': force}
+    policy_data = {'tag': tag['id'], 'action': 'unblock', 'package': pkg['id'],
+                   'force' : force, 'extra': tag['extra']}
     # don't check policy for admins using force
     assert_policy('package_list', policy_data, force=force)
     user = get_user(context.session.user_id)
diff --git a/plugins/cli/sidetag_cli.py b/plugins/cli/sidetag_cli.py
index cc55157..6ec5a9d 100644
--- a/plugins/cli/sidetag_cli.py
+++ b/plugins/cli/sidetag_cli.py
@@ -93,3 +93,29 @@ def handle_list_sidetags(options, session, args):
 
     for tag in session.listSideTags(basetag=opts.basetag, user=user):
         print(tag["name"])
+
+
+@export_cli
+def handle_edit_sidetag(options, session, args):
+    "Edit sidetag"
+    usage = _("usage: %(prog)s edit-sidetag [options]")
+    usage += _("\n(Specify the --help global option for a list of other help options)")
+    parser = ArgumentParser(usage=usage)
+    parser.add_argument("sidetag", help="name of sidetag")
+    parser.add_argument("--debuginfo", action="store_true", default=None,
+                        help=_("Generate debuginfo repository"))
+    parser.add_argument("--no-debuginfo", action="store_false", dest="debuginfo")
+    parser.add_argument("-b", "--block", action="append", help="block package")
+    parser.add_argument("-u", "--unblock", action="append", help="unblock package")
+
+    opts = parser.parse_args(args)
+
+    activate_session(session, options)
+
+    kwargs = {
+        'block_pkgs': opts.block,
+        'unblock_pkgs': opts.unblock,
+    }
+    if opts.debuginfo is not None:
+        kwargs['debuginfo'] = opts.debuginfo
+    session.editSideTag(opts.sidetag, **kwargs)
diff --git a/plugins/hub/sidetag_hub.py b/plugins/hub/sidetag_hub.py
index 6b29e1b..5c90e48 100644
--- a/plugins/hub/sidetag_hub.py
+++ b/plugins/hub/sidetag_hub.py
@@ -18,6 +18,7 @@ from kojihub import (  # noqa: F402
     get_tag,
     get_user,
     nextval
+    _edit_tag,
 )
 
 CONFIG_FILE = "/etc/koji-hub/plugins/sidetag.conf"
@@ -170,6 +171,49 @@ def listSideTags(basetag=None, user=None, queryOpts=None):
     return query.execute()
 
 
+@export
+def editSideTag(sidetag, debuginfo=None, block_pkgs=None, unblock_pkgs=None):
+    """Restricted ability to modify sidetags, parent tag must have:
+    sidetag_debuginfo_allowed: 1
+    sidetag_package_list_allowed: 1
+    in extra, if modifying functions should work. For blocking/unblocking
+    further policy must be compatible with these operations.
+
+    :param sidetag: sidetag id or name
+    :type sidetag: int or str
+    :param debuginfo: set or disable debuginfo repo generation
+    :type debuginfo: bool
+    :param block_pkgs: package names to be blocked in sidetag
+    :type block_pkgs: list of str
+    :param unblock_pkgs: package names to be unblocked in sidetag
+    :type unblock_pkgs: list of str
+    """
+
+    context.session.assertLogin()
+    user = get_user(context.session.user_id, strict=True)
+    tag = get_tag(sidetag, strict=True)
+
+    if not sidetag["extra"].get("sidetag"):
+        raise koji.GenericError("Not a sidetag: %(name)s" % sidetag)
+    if sidetag["extra"].get("sidetag_user_id") != user["id"]:
+        if not context.session.hasPerm("admin"):
+            raise koji.ActionNotAllowed("This is not your sidetag")
+
+    parent_id = getInheritanceData(sidetag)[0]['parent_id']
+    parent = get_tag(parent_id)
+
+    if debuginfo is not None and not parent['extra'].get('sidetag_debuginfo_allowed'):
+        raise koji.GenericError("Debuginfo setting is not allowed in parent tag.")
+    if (block_pkgs or unblock_pkgs) and not parent['extra'].get('sidetag_package_list_allowed'):
+        raise koji.GenericError("Package un/blocking is not allowed in parent tag.")
+
+    if debuginfo is not None:
+        _edit_tag(sidetag, extra={'with_debuginfo': bool(debuginfo)})
+    for pkg in block_pkgs:
+        pkglist_block(sidetag, pkg)
+    for pkg in unblock_pkgs:
+        pkglist_unblock(sidetag, pkg)
+
 def handle_sidetag_untag(cbtype, *args, **kws):
     """Remove a side tag when its last build is untagged
 
diff --git a/tests/test_hub/test_pkglist.py b/tests/test_hub/test_pkglist.py
index f9f6c2a..7ab2642 100644
--- a/tests/test_hub/test_pkglist.py
+++ b/tests/test_hub/test_pkglist.py
@@ -58,7 +58,7 @@ class TestPkglistBlock(unittest.TestCase):
     @mock.patch('kojihub.lookup_package')
     def test_pkglist_unblock(self, lookup_package, get_tag, assert_policy,
             readPackageList, _pkglist_add, _pkglist_remove):
-        tag = {'id': 1, 'name': 'tag'}
+        tag = {'id': 1, 'name': 'tag', 'extra': {}}
         pkg = {'id': 2, 'name': 'package', 'owner_id': 3}
         get_tag.return_value = tag
         lookup_package.return_value = pkg
@@ -73,7 +73,7 @@ class TestPkglistBlock(unittest.TestCase):
         get_tag.assert_called_once_with('tag', strict=True)
         lookup_package.assert_called_once_with('pkg', strict=True)
         assert_policy.assert_called_once_with('package_list', {'tag': tag['id'],
-            'action': 'unblock', 'package': pkg['id'], 'force': False}, force=False)
+            'action': 'unblock', 'package': pkg['id'], 'force': False, 'extra': {}}, force=False)
         self.assertEqual(readPackageList.call_count, 2)
         readPackageList.assert_has_calls([
             mock.call(tag['id'], pkgID=pkg['id'], inherit=True),
@@ -96,7 +96,7 @@ class TestPkglistBlock(unittest.TestCase):
     def test_pkglist_unblock_inherited(self, lookup_package, get_tag, assert_policy,
             readPackageList, _pkglist_add, _pkglist_remove):
         tag_id, pkg_id, owner_id = 1, 2, 3
-        get_tag.return_value = {'id': tag_id, 'name': 'tag'}
+        get_tag.return_value = {'id': tag_id, 'name': 'tag', 'extra': {}}
         lookup_package.return_value = {'id': pkg_id, 'name': 'pkg'}
         readPackageList.return_value = {pkg_id: {
             'blocked': True,
@@ -109,7 +109,7 @@ class TestPkglistBlock(unittest.TestCase):
         get_tag.assert_called_once_with('tag', strict=True)
         lookup_package.assert_called_once_with('pkg', strict=True)
         assert_policy.assert_called_once_with('package_list', {'tag': tag_id,
-            'action': 'unblock', 'package': pkg_id, 'force': False}, force=False)
+            'action': 'unblock', 'package': pkg_id, 'force': False, 'extra': {}}, force=False)
         readPackageList.assert_called_once_with(tag_id, pkgID=pkg_id, inherit=True)
         _pkglist_add.assert_called_once_with(tag_id, pkg_id, owner_id, False, '')
         _pkglist_remove.assert_not_called()
@@ -123,7 +123,7 @@ class TestPkglistBlock(unittest.TestCase):
     def test_pkglist_unblock_not_present(self, lookup_package, get_tag, assert_policy,
             readPackageList, _pkglist_add, _pkglist_remove):
         tag_id, pkg_id = 1, 2
-        get_tag.return_value = {'id': tag_id, 'name': 'tag'}
+        get_tag.return_value = {'id': tag_id, 'name': 'tag', 'extra': {}}
         lookup_package.return_value = {'id': pkg_id, 'name': 'pkg'}
         readPackageList.return_value = {}
 
@@ -133,7 +133,7 @@ class TestPkglistBlock(unittest.TestCase):
         get_tag.assert_called_once_with('tag', strict=True)
         lookup_package.assert_called_once_with('pkg', strict=True)
         assert_policy.assert_called_once_with('package_list', {'tag': tag_id,
-            'action': 'unblock', 'package': pkg_id, 'force': False}, force=False)
+            'action': 'unblock', 'package': pkg_id, 'force': False, 'extra': {}}, force=False)
         readPackageList.assert_called_once_with(tag_id, pkgID=pkg_id, inherit=True)
         _pkglist_add.assert_not_called()
         _pkglist_remove.assert_not_called()
@@ -147,7 +147,7 @@ class TestPkglistBlock(unittest.TestCase):
     def test_pkglist_unblock_not_blocked(self, lookup_package, get_tag, assert_policy,
             readPackageList, _pkglist_add, _pkglist_remove):
         tag_id, pkg_id, owner_id = 1, 2, 3
-        get_tag.return_value = {'id': tag_id, 'name': 'tag'}
+        get_tag.return_value = {'id': tag_id, 'name': 'tag', 'extra': {}}
         lookup_package.return_value = {'id': pkg_id, 'name': 'pkg'}
         readPackageList.return_value = {pkg_id: {
             'blocked': False,
@@ -162,7 +162,7 @@ class TestPkglistBlock(unittest.TestCase):
         get_tag.assert_called_once_with('tag', strict=True)
         lookup_package.assert_called_once_with('pkg', strict=True)
         assert_policy.assert_called_once_with('package_list', {'tag': tag_id,
-            'action': 'unblock', 'package': pkg_id, 'force': False}, force=False)
+            'action': 'unblock', 'package': pkg_id, 'force': False, 'extra': {}}, force=False)
         readPackageList.assert_called_once_with(tag_id, pkgID=pkg_id, inherit=True)
         _pkglist_add.assert_not_called()
         _pkglist_remove.assert_not_called()
@@ -200,7 +200,7 @@ class TestPkglistBlock(unittest.TestCase):
         force=False
         update=False
         policy=True
-        tag = {'id': 1, 'name': 'tag'}
+        tag = {'id': 1, 'name': 'tag', 'extra': {}}
         pkg = {'id': 2, 'name': 'pkg', 'owner_id': 3}
         users = [
             {'id': 3, 'name': 'user'},
@@ -224,7 +224,7 @@ class TestPkglistBlock(unittest.TestCase):
             mock.call(112233),
         ])
         assert_policy.assert_called_once_with('package_list', {'tag': tag['id'],
-            'action': 'add', 'package': pkg['name'], 'force': False}, force=False)
+            'action': 'add', 'package': pkg['name'], 'force': False, 'extra': {}}, force=False)
         self.assertEqual(self.run_callbacks.call_count, 2)
         self.run_callbacks.assert_has_calls([
             mock.call('prePackageListChange', action='add', tag=tag,
@@ -313,7 +313,7 @@ class TestPkglistBlock(unittest.TestCase):
         force=False
         update=False
         policy=True
-        tag = {'id': 1, 'name': 'tag'}
+        tag = {'id': 1, 'name': 'tag', 'extra': {}}
         pkg = {'id': 2, 'name': 'pkg', 'owner_id': 3}
         users = [
             {'id': 3, 'name': 'user'},
@@ -341,7 +341,7 @@ class TestPkglistBlock(unittest.TestCase):
             mock.call(112233),
         ])
         assert_policy.assert_called_once_with('package_list', {'tag': tag['id'],
-            'action': 'add', 'package': pkg['name'], 'force': False}, force=False)
+            'action': 'add', 'package': pkg['name'], 'force': False, 'extra': {}}, force=False)
         self.assertEqual(self.run_callbacks.call_count, 2)
         self.run_callbacks.assert_has_calls([
             mock.call('prePackageListChange', action='add', tag=tag,
@@ -370,7 +370,7 @@ class TestPkglistBlock(unittest.TestCase):
         force=False
         update=False
         policy=True
-        tag = {'id': 1, 'name': 'tag'}
+        tag = {'id': 1, 'name': 'tag', 'extra': {}}
         pkg = {'id': 2, 'name': 'pkg', 'owner_id': 3}
         users = [
             {'id': 3, 'name': 'user',},
@@ -398,7 +398,7 @@ class TestPkglistBlock(unittest.TestCase):
             mock.call(112233),
         ])
         assert_policy.assert_called_once_with('package_list', {'tag': tag['id'],
-            'action': 'add', 'package': pkg['name'], 'force': False}, force=False)
+            'action': 'add', 'package': pkg['name'], 'force': False, 'extra': {}}, force=False)
         self.run_callbacks.assert_called_once_with(
                 'prePackageListChange', action='add', tag=tag,
                 package=pkg, owner=user['id'], block=block,
@@ -420,7 +420,7 @@ class TestPkglistBlock(unittest.TestCase):
         force=True
         update=False
         policy=True
-        tag = {'id': 1, 'name': 'tag'}
+        tag = {'id': 1, 'name': 'tag', 'extra': {}}
         pkg = {'id': 2, 'name': 'pkg', 'owner_id': 3}
         users = [
             {'id': 3, 'name': 'user',},

From a143c440ddcb2f61fb69021007fba6ab81ae7603 Mon Sep 17 00:00:00 2001
From: Tomas Kopecek <tkopecek@redhat.com>
Date: Apr 08 2020 10:48:14 +0000
Subject: [PATCH 2/9] remove un/block part


---

diff --git a/plugins/cli/sidetag_cli.py b/plugins/cli/sidetag_cli.py
index 6ec5a9d..32d4323 100644
--- a/plugins/cli/sidetag_cli.py
+++ b/plugins/cli/sidetag_cli.py
@@ -105,17 +105,12 @@ def handle_edit_sidetag(options, session, args):
     parser.add_argument("--debuginfo", action="store_true", default=None,
                         help=_("Generate debuginfo repository"))
     parser.add_argument("--no-debuginfo", action="store_false", dest="debuginfo")
-    parser.add_argument("-b", "--block", action="append", help="block package")
-    parser.add_argument("-u", "--unblock", action="append", help="unblock package")
 
     opts = parser.parse_args(args)
 
+    if opts.debuginfo is None:
+        parser.error("--debuginfo or --no-debuginfo must be specified")
+
     activate_session(session, options)
 
-    kwargs = {
-        'block_pkgs': opts.block,
-        'unblock_pkgs': opts.unblock,
-    }
-    if opts.debuginfo is not None:
-        kwargs['debuginfo'] = opts.debuginfo
-    session.editSideTag(opts.sidetag, **kwargs)
+    session.editSideTag(opts.sidetag, debuginfo=opts.debuginfo)
diff --git a/plugins/hub/sidetag_hub.py b/plugins/hub/sidetag_hub.py
index 5c90e48..5e60670 100644
--- a/plugins/hub/sidetag_hub.py
+++ b/plugins/hub/sidetag_hub.py
@@ -172,10 +172,9 @@ def listSideTags(basetag=None, user=None, queryOpts=None):
 
 
 @export
-def editSideTag(sidetag, debuginfo=None, block_pkgs=None, unblock_pkgs=None):
+def editSideTag(sidetag, debuginfo=None):
     """Restricted ability to modify sidetags, parent tag must have:
     sidetag_debuginfo_allowed: 1
-    sidetag_package_list_allowed: 1
     in extra, if modifying functions should work. For blocking/unblocking
     further policy must be compatible with these operations.
 
@@ -183,10 +182,6 @@ def editSideTag(sidetag, debuginfo=None, block_pkgs=None, unblock_pkgs=None):
     :type sidetag: int or str
     :param debuginfo: set or disable debuginfo repo generation
     :type debuginfo: bool
-    :param block_pkgs: package names to be blocked in sidetag
-    :type block_pkgs: list of str
-    :param unblock_pkgs: package names to be unblocked in sidetag
-    :type unblock_pkgs: list of str
     """
 
     context.session.assertLogin()
@@ -204,15 +199,10 @@ def editSideTag(sidetag, debuginfo=None, block_pkgs=None, unblock_pkgs=None):
 
     if debuginfo is not None and not parent['extra'].get('sidetag_debuginfo_allowed'):
         raise koji.GenericError("Debuginfo setting is not allowed in parent tag.")
-    if (block_pkgs or unblock_pkgs) and not parent['extra'].get('sidetag_package_list_allowed'):
-        raise koji.GenericError("Package un/blocking is not allowed in parent tag.")
 
     if debuginfo is not None:
         _edit_tag(sidetag, extra={'with_debuginfo': bool(debuginfo)})
-    for pkg in block_pkgs:
-        pkglist_block(sidetag, pkg)
-    for pkg in unblock_pkgs:
-        pkglist_unblock(sidetag, pkg)
+
 
 def handle_sidetag_untag(cbtype, *args, **kws):
     """Remove a side tag when its last build is untagged

From 5668436073eee5dfea9f1f2119553ac7f1318f9b Mon Sep 17 00:00:00 2001
From: Tomas Kopecek <tkopecek@redhat.com>
Date: Apr 08 2020 10:48:14 +0000
Subject: [PATCH 3/9] introduce is_sidetag_owner policy


---

diff --git a/plugins/hub/sidetag_hub.py b/plugins/hub/sidetag_hub.py
index 5e60670..8bdad2c 100644
--- a/plugins/hub/sidetag_hub.py
+++ b/plugins/hub/sidetag_hub.py
@@ -4,6 +4,7 @@
 import sys
 
 import koji
+import koji.policy
 from koji.context import context
 from koji.plugin import callback, export
 sys.path.insert(0, "/usr/share/koji-hub/")
@@ -13,18 +14,31 @@ from kojihub import (  # noqa: F402
     _create_tag,
     _delete_build_target,
     _delete_tag,
+    _edit_tag,
     assert_policy,
     get_build_target,
+    getInheritanceData,
     get_tag,
     get_user,
-    nextval
-    _edit_tag,
+    nextval,
+    policy_get_user
 )
 
 CONFIG_FILE = "/etc/koji-hub/plugins/sidetag.conf"
 CONFIG = None
 
 
+class SidetagOwner(koji.policy.MatchTest):
+    """Checks, if user is a real owner of sidetag"""
+    name = 'is_sidetag_owner'
+
+    def run(self, data):
+        user = policy_get_user(data)
+        tag = get_tag(data['tag'])
+        return (tag['extra'].get('sidetag') and
+                tag['extra'].get('sidetag_user_id') == user['id'])
+
+
 @export
 def createSideTag(basetag, debuginfo=False):
     """Create a side tag.
@@ -186,7 +200,7 @@ def editSideTag(sidetag, debuginfo=None):
 
     context.session.assertLogin()
     user = get_user(context.session.user_id, strict=True)
-    tag = get_tag(sidetag, strict=True)
+    sidetag = get_tag(sidetag, strict=True)
 
     if not sidetag["extra"].get("sidetag"):
         raise koji.GenericError("Not a sidetag: %(name)s" % sidetag)

From 4b5a16008d37293b9fef48f33b04334e324361c0 Mon Sep 17 00:00:00 2001
From: Tomas Kopecek <tkopecek@redhat.com>
Date: Apr 08 2020 10:48:51 +0000
Subject: [PATCH 4/9] revert main hub changes


---

diff --git a/hub/kojihub.py b/hub/kojihub.py
index 6b32e31..bdc435d 100644
--- a/hub/kojihub.py
+++ b/hub/kojihub.py
@@ -990,8 +990,7 @@ def _direct_pkglist_add(taginfo, pkginfo, owner, block, extra_arches, force,
         action = 'block'
     if policy:
         context.session.assertLogin()
-        policy_data = {'tag': tag_id, 'action': action, 'package': pkginfo,
-                       'force' : force, 'extra': tag['extra']}
+        policy_data = {'tag': tag_id, 'action': action, 'package': pkginfo, 'force': force}
         # don't check policy for admins using force
         if not (force and context.session.hasPerm('admin')):
             assert_policy('package_list', policy_data)
@@ -1071,8 +1070,7 @@ def _direct_pkglist_remove(taginfo, pkginfo, force=False, policy=False):
     pkg = lookup_package(pkginfo, strict=True)
     if policy:
         context.session.assertLogin()
-        policy_data = {'tag': tag['id'], 'action': 'remove', 'package': pkg['id'],
-                       'force' : force, 'extra': tag['extra']}
+        policy_data = {'tag': tag['id'], 'action': 'remove', 'package': pkg['id'], 'force': force}
         # don't check policy for admins using force
         assert_policy('package_list', policy_data, force=force)
 
@@ -1104,8 +1102,7 @@ def pkglist_unblock(taginfo, pkginfo, force=False):
     tag = get_tag(taginfo, strict=True)
     pkg = lookup_package(pkginfo, strict=True)
     context.session.assertLogin()
-    policy_data = {'tag': tag['id'], 'action': 'unblock', 'package': pkg['id'],
-                   'force' : force, 'extra': tag['extra']}
+    policy_data = {'tag': tag['id'], 'action': 'unblock', 'package': pkg['id'], 'force': force}
     # don't check policy for admins using force
     assert_policy('package_list', policy_data, force=force)
     user = get_user(context.session.user_id)
diff --git a/tests/test_hub/test_pkglist.py b/tests/test_hub/test_pkglist.py
index 7ab2642..8c8b18e 100644
--- a/tests/test_hub/test_pkglist.py
+++ b/tests/test_hub/test_pkglist.py
@@ -58,7 +58,7 @@ class TestPkglistBlock(unittest.TestCase):
     @mock.patch('kojihub.lookup_package')
     def test_pkglist_unblock(self, lookup_package, get_tag, assert_policy,
             readPackageList, _pkglist_add, _pkglist_remove):
-        tag = {'id': 1, 'name': 'tag', 'extra': {}}
+        tag = {'id': 1, 'name': 'tag'}
         pkg = {'id': 2, 'name': 'package', 'owner_id': 3}
         get_tag.return_value = tag
         lookup_package.return_value = pkg
@@ -96,7 +96,7 @@ class TestPkglistBlock(unittest.TestCase):
     def test_pkglist_unblock_inherited(self, lookup_package, get_tag, assert_policy,
             readPackageList, _pkglist_add, _pkglist_remove):
         tag_id, pkg_id, owner_id = 1, 2, 3
-        get_tag.return_value = {'id': tag_id, 'name': 'tag', 'extra': {}}
+        get_tag.return_value = {'id': tag_id, 'name': 'tag'}
         lookup_package.return_value = {'id': pkg_id, 'name': 'pkg'}
         readPackageList.return_value = {pkg_id: {
             'blocked': True,
@@ -123,7 +123,7 @@ class TestPkglistBlock(unittest.TestCase):
     def test_pkglist_unblock_not_present(self, lookup_package, get_tag, assert_policy,
             readPackageList, _pkglist_add, _pkglist_remove):
         tag_id, pkg_id = 1, 2
-        get_tag.return_value = {'id': tag_id, 'name': 'tag', 'extra': {}}
+        get_tag.return_value = {'id': tag_id, 'name': 'tag'}
         lookup_package.return_value = {'id': pkg_id, 'name': 'pkg'}
         readPackageList.return_value = {}
 
@@ -147,7 +147,7 @@ class TestPkglistBlock(unittest.TestCase):
     def test_pkglist_unblock_not_blocked(self, lookup_package, get_tag, assert_policy,
             readPackageList, _pkglist_add, _pkglist_remove):
         tag_id, pkg_id, owner_id = 1, 2, 3
-        get_tag.return_value = {'id': tag_id, 'name': 'tag', 'extra': {}}
+        get_tag.return_value = {'id': tag_id, 'name': 'tag'}
         lookup_package.return_value = {'id': pkg_id, 'name': 'pkg'}
         readPackageList.return_value = {pkg_id: {
             'blocked': False,
@@ -200,7 +200,7 @@ class TestPkglistBlock(unittest.TestCase):
         force=False
         update=False
         policy=True
-        tag = {'id': 1, 'name': 'tag', 'extra': {}}
+        tag = {'id': 1, 'name': 'tag'}
         pkg = {'id': 2, 'name': 'pkg', 'owner_id': 3}
         users = [
             {'id': 3, 'name': 'user'},
@@ -313,7 +313,7 @@ class TestPkglistBlock(unittest.TestCase):
         force=False
         update=False
         policy=True
-        tag = {'id': 1, 'name': 'tag', 'extra': {}}
+        tag = {'id': 1, 'name': 'tag'}
         pkg = {'id': 2, 'name': 'pkg', 'owner_id': 3}
         users = [
             {'id': 3, 'name': 'user'},
@@ -370,7 +370,7 @@ class TestPkglistBlock(unittest.TestCase):
         force=False
         update=False
         policy=True
-        tag = {'id': 1, 'name': 'tag', 'extra': {}}
+        tag = {'id': 1, 'name': 'tag'}
         pkg = {'id': 2, 'name': 'pkg', 'owner_id': 3}
         users = [
             {'id': 3, 'name': 'user',},
@@ -420,7 +420,7 @@ class TestPkglistBlock(unittest.TestCase):
         force=True
         update=False
         policy=True
-        tag = {'id': 1, 'name': 'tag', 'extra': {}}
+        tag = {'id': 1, 'name': 'tag'}
         pkg = {'id': 2, 'name': 'pkg', 'owner_id': 3}
         users = [
             {'id': 3, 'name': 'user',},

From 39d45e550fd84265f6386c8c182e176ccb85e138 Mon Sep 17 00:00:00 2001
From: Tomas Kopecek <tkopecek@redhat.com>
Date: Apr 08 2020 10:48:51 +0000
Subject: [PATCH 5/9] add is_sidetag policy test


---

diff --git a/plugins/hub/sidetag_hub.py b/plugins/hub/sidetag_hub.py
index 8bdad2c..add40e7 100644
--- a/plugins/hub/sidetag_hub.py
+++ b/plugins/hub/sidetag_hub.py
@@ -28,7 +28,16 @@ CONFIG_FILE = "/etc/koji-hub/plugins/sidetag.conf"
 CONFIG = None
 
 
-class SidetagOwner(koji.policy.MatchTest):
+class SidetagTest(koji.policy.MatchTest):
+    """Checks, if tag is a sidetag"""
+    name = 'is_sidetag'
+
+    def run(self, data):
+        tag = get_tag(data['tag'])
+        return bool(tag['extra'].get('sidetag'))
+
+
+class SidetagOwnerTest(koji.policy.MatchTest):
     """Checks, if user is a real owner of sidetag"""
     name = 'is_sidetag_owner'
 

From f603231450c38ff7522e8b56c5971edc39e67a9c Mon Sep 17 00:00:00 2001
From: Tomas Kopecek <tkopecek@redhat.com>
Date: Apr 08 2020 10:48:51 +0000
Subject: [PATCH 6/9] simplify checks


---

diff --git a/docs/source/plugins.rst b/docs/source/plugins.rst
index 28bbf7c..c1e9d09 100644
--- a/docs/source/plugins.rst
+++ b/docs/source/plugins.rst
@@ -93,6 +93,14 @@ Example for `/etc/koji-hub/hub.conf`:
         # forbid everything else
         all :: deny
 
+    package_list =
+        # allow blocking for owners in their sidetags
+        match action block && is_sidetag_owner :: allow
+        all :: deny
+
+There are two special policy tests `is_sidetag` and `is_sidetag_owner` with
+expectable behaviour.
+
 Now Sidetag Koji plugin should be installed.  To verify that, run
 `koji list-api` command -- it should now display `createSideTag`
 as one of available API calls.
diff --git a/plugins/hub/sidetag_hub.py b/plugins/hub/sidetag_hub.py
index add40e7..a887dbb 100644
--- a/plugins/hub/sidetag_hub.py
+++ b/plugins/hub/sidetag_hub.py
@@ -28,13 +28,29 @@ CONFIG_FILE = "/etc/koji-hub/plugins/sidetag.conf"
 CONFIG = None
 
 
+def is_sidetag(taginfo, raise_error=False):
+    """Check, that given tag is sidetag"""
+    result = bool(taginfo['extra'].get('sidetag'))
+    if not result and raise_error:
+        raise koji.GenericError("Not a sidetag: %(name)s" % taginfo)
+
+
+def is_sidetag_owner(taginfo, user, raise_error=False):
+    """Check, that given user is owner of the sidetag"""
+    result = (taginfo['extra'].get('sidetag') and
+              taginfo['extra'].get('sidetag_user_id') == user['id'])
+    if not result and raise_error:
+        raise koji.ActionNotAllowed("This is not your sidetag")
+
+
+# Policy tests
 class SidetagTest(koji.policy.MatchTest):
     """Checks, if tag is a sidetag"""
     name = 'is_sidetag'
 
     def run(self, data):
         tag = get_tag(data['tag'])
-        return bool(tag['extra'].get('sidetag'))
+        return is_sidetag(tag)
 
 
 class SidetagOwnerTest(koji.policy.MatchTest):
@@ -44,10 +60,10 @@ class SidetagOwnerTest(koji.policy.MatchTest):
     def run(self, data):
         user = policy_get_user(data)
         tag = get_tag(data['tag'])
-        return (tag['extra'].get('sidetag') and
-                tag['extra'].get('sidetag_user_id') == user['id'])
+        return is_sidetag_owner(tag, user)
 
 
+# API calls
 @export
 def createSideTag(basetag, debuginfo=False):
     """Create a side tag.
@@ -118,11 +134,9 @@ def removeSideTag(sidetag):
     sidetag = get_tag(sidetag, strict=True)
 
     # sanity/access
-    if not sidetag["extra"].get("sidetag"):
-        raise koji.GenericError("Not a sidetag: %(name)s" % sidetag)
-    if sidetag["extra"].get("sidetag_user_id") != user["id"]:
-        if not context.session.hasPerm("admin"):
-            raise koji.ActionNotAllowed("This is not your sidetag")
+    is_sidetag(sidetag, raise_error=True)
+    is_sidetag_owner(sidetag, user, raise_error=True)
+
     _remove_sidetag(sidetag)
 
 
@@ -211,11 +225,9 @@ def editSideTag(sidetag, debuginfo=None):
     user = get_user(context.session.user_id, strict=True)
     sidetag = get_tag(sidetag, strict=True)
 
-    if not sidetag["extra"].get("sidetag"):
-        raise koji.GenericError("Not a sidetag: %(name)s" % sidetag)
-    if sidetag["extra"].get("sidetag_user_id") != user["id"]:
-        if not context.session.hasPerm("admin"):
-            raise koji.ActionNotAllowed("This is not your sidetag")
+    # sanity/access
+    is_sidetag(sidetag, raise_error=True)
+    is_sidetag_owner(sidetag, user, raise_error=True)
 
     parent_id = getInheritanceData(sidetag)[0]['parent_id']
     parent = get_tag(parent_id)
@@ -241,8 +253,7 @@ def handle_sidetag_untag(cbtype, *args, **kws):
     if not tag:
         # also shouldn't happen, but just in case
         return
-    if not tag["extra"].get("sidetag"):
-        # not a side tag
+    if not is_sidetag(tag):
         return
     # is the tag now empty?
     query = QueryProcessor(

From fa4a4a6339bd40dfb3d62c2f8dbaab1d72140645 Mon Sep 17 00:00:00 2001
From: Tomas Kopecek <tkopecek@redhat.com>
Date: Apr 08 2020 10:48:51 +0000
Subject: [PATCH 7/9] edit rpm macros


---

diff --git a/plugins/cli/sidetag_cli.py b/plugins/cli/sidetag_cli.py
index 32d4323..0443c76 100644
--- a/plugins/cli/sidetag_cli.py
+++ b/plugins/cli/sidetag_cli.py
@@ -105,12 +105,31 @@ def handle_edit_sidetag(options, session, args):
     parser.add_argument("--debuginfo", action="store_true", default=None,
                         help=_("Generate debuginfo repository"))
     parser.add_argument("--no-debuginfo", action="store_false", dest="debuginfo")
+    parser.add_argument("--rpm-macro", action="append", default=[], metavar="key=value",
+                        help=_("Set tag-specific rpm macros"))
+    parser.add_argument("--remove-rpm-macro", action="append", default=[], metavar="key",
+                        help=_("Remove rpm macros"))
 
     opts = parser.parse_args(args)
 
-    if opts.debuginfo is None:
-        parser.error("--debuginfo or --no-debuginfo must be specified")
+    if opts.debuginfo is None and not opts.add_rpm_macro and not opts.remove_rpm_macros:
+        parser.error("At least one option needs to be specified")
 
     activate_session(session, options)
 
-    session.editSideTag(opts.sidetag, debuginfo=opts.debuginfo)
+    kwargs = {}
+    if opts.debuginfo is not None:
+        kwargs['debuginfo'] = opts.debuginfo
+
+    if options.add_rpm_macro:
+        rpm_macros = {]
+        for xopt in opts.add_rpm_macro:
+            key, value = xopt.split('=', 1)
+            value = arg_filter(value)
+            rpm_macros[key] = value
+        kwargs['rpm_macros'] = rpm_macros
+
+    if opts.remove_rpm_macros:
+        kwargs['remove_rpm_macros'] = opts.remove_rpm_macros
+
+    session.editSideTag(opts.sidetag, **kwargs)
diff --git a/plugins/hub/sidetag_hub.py b/plugins/hub/sidetag_hub.py
index a887dbb..dc3ee4f 100644
--- a/plugins/hub/sidetag_hub.py
+++ b/plugins/hub/sidetag_hub.py
@@ -209,9 +209,10 @@ def listSideTags(basetag=None, user=None, queryOpts=None):
 
 
 @export
-def editSideTag(sidetag, debuginfo=None):
+def editSideTag(sidetag, debuginfo=None, rpm_macros=None, remove_rpm_macros=None):
     """Restricted ability to modify sidetags, parent tag must have:
     sidetag_debuginfo_allowed: 1
+    sidetag_rpm_macros_allowed: 1
     in extra, if modifying functions should work. For blocking/unblocking
     further policy must be compatible with these operations.
 
@@ -219,6 +220,10 @@ def editSideTag(sidetag, debuginfo=None):
     :type sidetag: int or str
     :param debuginfo: set or disable debuginfo repo generation
     :type debuginfo: bool
+    :param rpm_macros: add/update rpms macros in extra
+    :type rpm_macros: dict
+    :param remove_rpm_macros: remove rpm macros from extra
+    :type remove_rpm_macros: list of str
     """
 
     context.session.assertLogin()
@@ -235,8 +240,19 @@ def editSideTag(sidetag, debuginfo=None):
     if debuginfo is not None and not parent['extra'].get('sidetag_debuginfo_allowed'):
         raise koji.GenericError("Debuginfo setting is not allowed in parent tag.")
 
+    if ((rpm_macros is not None or remove_rpm_macros is not None)
+        and not parent['extra'].get('sidetag_rpm_macros_allowed')):
+        raise koji.GenericError("RPM macros change is not allowed in parent tag.")
+
+    kwargs = {'extra': {}}
     if debuginfo is not None:
-        _edit_tag(sidetag, extra={'with_debuginfo': bool(debuginfo)})
+        kwargs['extra']['with_debuginfo'] = bool(debuginfo)
+    for macro, value in rpm_macros.items():
+        kwargs['extra']['rpm.macro.%s' % macro] = value
+    if remove_rpm_macros:
+        kwargs['remove_extra'] = ['rpm.macro.%s' % m for m in remove_rpm_macros]
+
+    _edit_tag(sidetag, **kwargs)
 
 
 def handle_sidetag_untag(cbtype, *args, **kws):

From 40c02699a69b3e6ece55320d86f8504d32f47195 Mon Sep 17 00:00:00 2001
From: Tomas Kopecek <tkopecek@redhat.com>
Date: Apr 08 2020 10:48:51 +0000
Subject: [PATCH 8/9] fixes


---

diff --git a/plugins/cli/sidetag_cli.py b/plugins/cli/sidetag_cli.py
index 0443c76..f44ac40 100644
--- a/plugins/cli/sidetag_cli.py
+++ b/plugins/cli/sidetag_cli.py
@@ -10,7 +10,7 @@ from argparse import ArgumentParser
 import koji
 from koji.plugin import export_cli
 from koji_cli.commands import anon_handle_wait_repo
-from koji_cli.lib import _, activate_session
+from koji_cli.lib import _, activate_session, arg_filter
 
 
 @export_cli
@@ -106,13 +106,13 @@ def handle_edit_sidetag(options, session, args):
                         help=_("Generate debuginfo repository"))
     parser.add_argument("--no-debuginfo", action="store_false", dest="debuginfo")
     parser.add_argument("--rpm-macro", action="append", default=[], metavar="key=value",
-                        help=_("Set tag-specific rpm macros"))
+                        dest="rpm_macros", help=_("Set tag-specific rpm macros"))
     parser.add_argument("--remove-rpm-macro", action="append", default=[], metavar="key",
-                        help=_("Remove rpm macros"))
+                        dest="remove_rpm_macros", help=_("Remove rpm macros"))
 
     opts = parser.parse_args(args)
 
-    if opts.debuginfo is None and not opts.add_rpm_macro and not opts.remove_rpm_macros:
+    if opts.debuginfo is None and not opts.rpm_macros and not opts.remove_rpm_macros:
         parser.error("At least one option needs to be specified")
 
     activate_session(session, options)
@@ -121,9 +121,9 @@ def handle_edit_sidetag(options, session, args):
     if opts.debuginfo is not None:
         kwargs['debuginfo'] = opts.debuginfo
 
-    if options.add_rpm_macro:
-        rpm_macros = {]
-        for xopt in opts.add_rpm_macro:
+    if opts.rpm_macros:
+        rpm_macros = {}
+        for xopt in opts.rpm_macros:
             key, value = xopt.split('=', 1)
             value = arg_filter(value)
             rpm_macros[key] = value
diff --git a/plugins/hub/sidetag_hub.py b/plugins/hub/sidetag_hub.py
index dc3ee4f..13cf878 100644
--- a/plugins/hub/sidetag_hub.py
+++ b/plugins/hub/sidetag_hub.py
@@ -17,7 +17,7 @@ from kojihub import (  # noqa: F402
     _edit_tag,
     assert_policy,
     get_build_target,
-    getInheritanceData,
+    readInheritanceData,
     get_tag,
     get_user,
     nextval,
@@ -234,25 +234,26 @@ def editSideTag(sidetag, debuginfo=None, rpm_macros=None, remove_rpm_macros=None
     is_sidetag(sidetag, raise_error=True)
     is_sidetag_owner(sidetag, user, raise_error=True)
 
-    parent_id = getInheritanceData(sidetag)[0]['parent_id']
+    parent_id = readInheritanceData(sidetag['id'])[0]['parent_id']
     parent = get_tag(parent_id)
 
     if debuginfo is not None and not parent['extra'].get('sidetag_debuginfo_allowed'):
         raise koji.GenericError("Debuginfo setting is not allowed in parent tag.")
 
-    if ((rpm_macros is not None or remove_rpm_macros is not None)
-        and not parent['extra'].get('sidetag_rpm_macros_allowed')):
+    if (rpm_macros is not None or remove_rpm_macros is not None) \
+            and not parent['extra'].get('sidetag_rpm_macros_allowed'):
         raise koji.GenericError("RPM macros change is not allowed in parent tag.")
 
     kwargs = {'extra': {}}
     if debuginfo is not None:
         kwargs['extra']['with_debuginfo'] = bool(debuginfo)
-    for macro, value in rpm_macros.items():
-        kwargs['extra']['rpm.macro.%s' % macro] = value
-    if remove_rpm_macros:
+    if rpm_macros is not None:
+        for macro, value in rpm_macros.items():
+            kwargs['extra']['rpm.macro.%s' % macro] = value
+    if remove_rpm_macros is not None:
         kwargs['remove_extra'] = ['rpm.macro.%s' % m for m in remove_rpm_macros]
 
-    _edit_tag(sidetag, **kwargs)
+    _edit_tag(sidetag['id'], **kwargs)
 
 
 def handle_sidetag_untag(cbtype, *args, **kws):

From b0ef16f29313dc816ad8f303db50d53403269014 Mon Sep 17 00:00:00 2001
From: Tomas Kopecek <tkopecek@redhat.com>
Date: Apr 08 2020 10:55:33 +0000
Subject: [PATCH 9/9] rebase fixes


---

diff --git a/hub/kojihub.py b/hub/kojihub.py
index bdc435d..5932415 100644
--- a/hub/kojihub.py
+++ b/hub/kojihub.py
@@ -991,9 +991,7 @@ def _direct_pkglist_add(taginfo, pkginfo, owner, block, extra_arches, force,
     if policy:
         context.session.assertLogin()
         policy_data = {'tag': tag_id, 'action': action, 'package': pkginfo, 'force': force}
-        # don't check policy for admins using force
-        if not (force and context.session.hasPerm('admin')):
-            assert_policy('package_list', policy_data)
+        assert_policy('package_list', policy_data, force=force)
     if not pkg:
         pkg = lookup_package(pkginfo, create=True)
     # validate arches before running callbacks
diff --git a/tests/test_hub/test_pkglist.py b/tests/test_hub/test_pkglist.py
index 8c8b18e..f9f6c2a 100644
--- a/tests/test_hub/test_pkglist.py
+++ b/tests/test_hub/test_pkglist.py
@@ -73,7 +73,7 @@ class TestPkglistBlock(unittest.TestCase):
         get_tag.assert_called_once_with('tag', strict=True)
         lookup_package.assert_called_once_with('pkg', strict=True)
         assert_policy.assert_called_once_with('package_list', {'tag': tag['id'],
-            'action': 'unblock', 'package': pkg['id'], 'force': False, 'extra': {}}, force=False)
+            'action': 'unblock', 'package': pkg['id'], 'force': False}, force=False)
         self.assertEqual(readPackageList.call_count, 2)
         readPackageList.assert_has_calls([
             mock.call(tag['id'], pkgID=pkg['id'], inherit=True),
@@ -109,7 +109,7 @@ class TestPkglistBlock(unittest.TestCase):
         get_tag.assert_called_once_with('tag', strict=True)
         lookup_package.assert_called_once_with('pkg', strict=True)
         assert_policy.assert_called_once_with('package_list', {'tag': tag_id,
-            'action': 'unblock', 'package': pkg_id, 'force': False, 'extra': {}}, force=False)
+            'action': 'unblock', 'package': pkg_id, 'force': False}, force=False)
         readPackageList.assert_called_once_with(tag_id, pkgID=pkg_id, inherit=True)
         _pkglist_add.assert_called_once_with(tag_id, pkg_id, owner_id, False, '')
         _pkglist_remove.assert_not_called()
@@ -133,7 +133,7 @@ class TestPkglistBlock(unittest.TestCase):
         get_tag.assert_called_once_with('tag', strict=True)
         lookup_package.assert_called_once_with('pkg', strict=True)
         assert_policy.assert_called_once_with('package_list', {'tag': tag_id,
-            'action': 'unblock', 'package': pkg_id, 'force': False, 'extra': {}}, force=False)
+            'action': 'unblock', 'package': pkg_id, 'force': False}, force=False)
         readPackageList.assert_called_once_with(tag_id, pkgID=pkg_id, inherit=True)
         _pkglist_add.assert_not_called()
         _pkglist_remove.assert_not_called()
@@ -162,7 +162,7 @@ class TestPkglistBlock(unittest.TestCase):
         get_tag.assert_called_once_with('tag', strict=True)
         lookup_package.assert_called_once_with('pkg', strict=True)
         assert_policy.assert_called_once_with('package_list', {'tag': tag_id,
-            'action': 'unblock', 'package': pkg_id, 'force': False, 'extra': {}}, force=False)
+            'action': 'unblock', 'package': pkg_id, 'force': False}, force=False)
         readPackageList.assert_called_once_with(tag_id, pkgID=pkg_id, inherit=True)
         _pkglist_add.assert_not_called()
         _pkglist_remove.assert_not_called()
@@ -224,7 +224,7 @@ class TestPkglistBlock(unittest.TestCase):
             mock.call(112233),
         ])
         assert_policy.assert_called_once_with('package_list', {'tag': tag['id'],
-            'action': 'add', 'package': pkg['name'], 'force': False, 'extra': {}}, force=False)
+            'action': 'add', 'package': pkg['name'], 'force': False}, force=False)
         self.assertEqual(self.run_callbacks.call_count, 2)
         self.run_callbacks.assert_has_calls([
             mock.call('prePackageListChange', action='add', tag=tag,
@@ -341,7 +341,7 @@ class TestPkglistBlock(unittest.TestCase):
             mock.call(112233),
         ])
         assert_policy.assert_called_once_with('package_list', {'tag': tag['id'],
-            'action': 'add', 'package': pkg['name'], 'force': False, 'extra': {}}, force=False)
+            'action': 'add', 'package': pkg['name'], 'force': False}, force=False)
         self.assertEqual(self.run_callbacks.call_count, 2)
         self.run_callbacks.assert_has_calls([
             mock.call('prePackageListChange', action='add', tag=tag,
@@ -398,7 +398,7 @@ class TestPkglistBlock(unittest.TestCase):
             mock.call(112233),
         ])
         assert_policy.assert_called_once_with('package_list', {'tag': tag['id'],
-            'action': 'add', 'package': pkg['name'], 'force': False, 'extra': {}}, force=False)
+            'action': 'add', 'package': pkg['name'], 'force': False}, force=False)
         self.run_callbacks.assert_called_once_with(
                 'prePackageListChange', action='add', tag=tag,
                 package=pkg, owner=user['id'], block=block,

