Repository: fedora-infra/bodhi

Issue:
Don't show push buttons if user doesn't have sufficient rights on the update
We fixed #282, but it requires some followup.  Currently, if you try to set the request on an update you don't have rights on, you get correctly denied.  However, if you view an update page you may still be presented with buttons telling you that you _can_ set the request (even though you can't).

We can likely just invoke the same server-side logic used to guard the set_request service when deciding whether or not to show buttons.
- The logic for displaying (or not displaying) buttons currently resides in `bodhi/templates/update.html`.
- It relies on a `can_edit` field that gets computed in `bodhi/services/updates.py`.
- That field is derived from the `bodhi.security.packagers_allowed_acl` object which is attached to the cornice service.
- That is wrong in both name and substance.  Really, we only want the package **maintainers** to have `can_edit=True`... (which is supposed to be embodied in `bodhi.security.package_maintainers_only_acl`).  The `packagers_allowed_acl` evaluates to `True` for _any_ packager :disappointed:. 
- The logic that currently handles denying set_request requests is in `bodhi.validators.validate_acls`.  It has code paths in it that are superior to the logic in `bodhi.security.package_maintainers_only_acl`.  The later, for instance, only checks if you have direct commit on a package in pkgdb -- but it _does not_ check if you're in a group which has group commit access (a group like the _infra-sig_ group, for instance).

**So**, what we need to do is:
- [x] Write a test-case in `bodhi/tests/functional/test_updates.py` that _fails_ given the current code (it should hit the JSON api and ensure that `can_edit` is True/False given different setup conditions).
- [x] Re-organize `bodhi.security.package_maintainers_only_acl` and `bodhi.validators.validate_acls` so that they _share code_.  Take care that we do this correctly and without introducing any unnecessary overhead.
- [ ] ~~Apply the `bodhi.security.package_maintainers_only_acl` to that get_request service instead of `bodhi.security.packagers_allowed_acl`~~.
- [x] Ensure that our original test-case we wrote in step 1 _passes_ after we re-organize things.



Discussion:
Similar issue: The 'Edit' button is shown on each update and I can even fill in the edit form. Only when I confirm the edit, I get a permission denied error.

Right.  That 'edit' button is lumped into the [same conditional block](https://github.com/fedora-infra/bodhi/blob/develop/bodhi/templates/update.html#L463-L495) in the template.  A fix to the `can_edit` field should address all those cases.


Resolving patch:
diff --git a/bodhi/security.py b/bodhi/security.py
--- a/bodhi/security.py
+++ b/bodhi/security.py
@@ -12,14 +12,15 @@
 # along with this program; if not, write to the Free Software
 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
 
-from pyramid.security import (Allow, Deny, Everyone, Authenticated,
-                              ALL_PERMISSIONS, DENY_ALL)
+from cornice.errors import Errors
+
+from pyramid.security import (Allow, ALL_PERMISSIONS, DENY_ALL)
 from pyramid.security import remember, forget
 from pyramid.httpexceptions import HTTPFound
 from pyramid.threadlocal import get_current_registry
 
 from . import log
-from .models import User, Group, Update
+from .models import User, Group
 
 
 #
@@ -35,19 +36,10 @@ def admin_only_acl(request):
 
 def packagers_allowed_acl(request):
     """Generate an ACL for update submission"""
-    return [(Allow, 'group:' + group, ALL_PERMISSIONS) for group in
-            request.registry.settings['mandatory_packager_groups'].split()] + \
-           [DENY_ALL]
-
-
-def package_maintainers_only_acl(request):
-    """An ACL that only allows package maintainers for a given package"""
-    acl = admin_only_acl(request)
-    update = Update.get(request.matchdict['id'], request.db)
-    if update:
-        for committer in update.get_maintainers():
-            acl.insert(0, (Allow, committer.name, ALL_PERMISSIONS))
-    return acl
+    groups = request.registry.settings['mandatory_packager_groups'].split()
+    return [
+        (Allow, 'group:' + group, ALL_PERMISSIONS) for group in groups
+    ] + [DENY_ALL]
 
 
 #
@@ -189,3 +181,19 @@ def __contains__(self, item):
 
 cors_origins_ro = CorsOrigins('cors_origins_ro')
 cors_origins_rw = CorsOrigins('cors_origins_rw')
+
+
+class ProtectedRequest(object):
+    """ A proxy to the request object.
+
+    The point here is that you can set 'errors' on this request, but they
+    will be sent to /dev/null and hidden from cornice.  Otherwise, this
+    object behaves just like a normal request object.
+    """
+    def __init__(self, real_request):
+        # Hide errors added to this from the real request
+        self.errors = Errors()
+        # But proxy other attributes to the real request
+        self.real_request = real_request
+        for attr in ['db', 'registry', 'validated', 'buildinfo', 'user']:
+            setattr(self, attr, getattr(self.real_request, attr))
diff --git a/bodhi/services/updates.py b/bodhi/services/updates.py
--- a/bodhi/services/updates.py
+++ b/bodhi/services/updates.py
@@ -16,7 +16,6 @@
 import math
 
 from cornice import Service
-from pyramid.security import has_permission
 from sqlalchemy import func, distinct
 from sqlalchemy.sql import or_
 
@@ -45,18 +44,12 @@
 update = Service(name='update', path='/updates/{id}',
                  validators=(validate_update_id,),
                  description='Update submission service',
-                 # This acl only checks if the user is an admin or a commiters to the packages,
-                 # where as the validate_acls method which is attached to the @post on this
-                 # services does this as well as checking against the groups. So, this acl
-                 # should be unnecessary at the moment.
-                 #acl=bodhi.security.package_maintainers_only_acl,
                  acl=bodhi.security.packagers_allowed_acl,
                  cors_origins=bodhi.security.cors_origins_ro)
 
 update_edit = Service(name='update_edit', path='/updates/{id}/edit',
                  validators=(validate_update_id,),
                  description='Update submission service',
-                 #acl=bodhi.security.package_maintainers_only_acl,
                  acl=bodhi.security.packagers_allowed_acl,
                  cors_origins=bodhi.security.cors_origins_rw)
 
@@ -67,11 +60,9 @@
 
 update_request = Service(name='update_request', path='/updates/{id}/request',
                          description='Update request service',
-                         #acl=bodhi.security.package_maintainers_only_acl,
                          acl=bodhi.security.packagers_allowed_acl,
                          cors_origins=bodhi.security.cors_origins_rw)
 
-
 @update.get(accept=('application/json', 'text/json'), renderer='json',
             error_handler=bodhi.services.errors.json_handler)
 @update.get(accept=('application/javascript'), renderer='jsonp',
@@ -80,7 +71,12 @@
             error_handler=bodhi.services.errors.html_handler)
 def get_update(request):
     """Return a single update from an id, title, or alias"""
-    can_edit = bool(has_permission('edit', request.context, request))
+
+    proxy_request = bodhi.security.ProtectedRequest(request)
+    validate_acls(proxy_request)
+    # If validate_acls produced 0 errors, then we can edit this update.
+    can_edit = len(proxy_request.errors) == 0
+
     return dict(update=request.validated['update'], can_edit=can_edit)
 
 
diff --git a/bodhi/validators.py b/bodhi/validators.py
--- a/bodhi/validators.py
+++ b/bodhi/validators.py
@@ -285,10 +285,10 @@ def validate_acls(request):
         admin_groups = settings['admin_packager_groups'].split()
         for group in admin_groups:
             if group in user_groups:
-                log.debug(
-                    '{} is in {} admin group'.format(user.name, group))
+                log.debug('{} is in {} admin group'.format(user.name, group))
                 has_access = True
                 break
+
         if has_access:
             continue
 
@@ -721,7 +721,6 @@ def validate_override_builds(request):
 def _validate_override_build(request, nvr, db):
     """ Workhorse function for validate_override_builds """
     build = Build.get(nvr, db)
-    print "in _validate with build", build
     if build is not None:
         if not build.release:
             # Oddly, the build has no associated release.  Let's try to figure


Test patch:
diff --git a/bodhi/tests/functional/test_updates.py b/bodhi/tests/functional/test_updates.py
--- a/bodhi/tests/functional/test_updates.py
+++ b/bodhi/tests/functional/test_updates.py
@@ -248,6 +248,7 @@ def test_provenpackager_request_privs(self, publish, *args):
         user = User(name=u'bob')
         session.add(user)
         session.add(User(name=u'ralph'))  # Add a non proventester
+        session.add(User(name=u'someuser'))  # An unrelated user with no privs
         session.flush()
         group = session.query(Group).filter_by(name=u'provenpackager').one()
         user.groups.append(group)
@@ -320,6 +321,22 @@ def test_provenpackager_request_privs(self, publish, *args):
         eq_(update.request, None)
         eq_(update.status, UpdateStatus.obsolete)
 
+        # Test that bob has can_edit True, provenpackager
+        app = TestApp(main({}, testing=u'bob', **self.app_settings))
+        res = app.get('/updates/%s' % nvr, status=200)
+        eq_(res.json_body['can_edit'], True)
+
+        # Test that ralph has can_edit True, they submitted it.
+        app = TestApp(main({}, testing=u'ralph', **self.app_settings))
+        res = app.get('/updates/%s' % nvr, status=200)
+        eq_(res.json_body['can_edit'], True)
+
+        # Test that someuser has can_edit False, they are unrelated
+        # This check *failed* with the old acls code.
+        app = TestApp(main({}, testing=u'someuser', **self.app_settings))
+        res = app.get('/updates/%s' % nvr, status=200)
+        eq_(res.json_body['can_edit'], False)
+
     @mock.patch(**mock_valid_requirements)
     def test_pkgdb_outage(self, *args):
         "Test the case where our call to the pkgdb throws an exception"