Repository: Kinto/kinto

Issue:
Return 404 on non-existing objects for users with read permissions
When using patch and delete on non-existing objects, the users receive a 404 only if they have `write` permissions on the parent, otherwise 403 is returned to prevent leaking information, but as discussed on #915, it also makes sense to return 404 for users with `read` permissions on the parent.
Return 404 on non-existing objects for users with read permissions
When using patch and delete on non-existing objects, the users receive a 404 only if they have `write` permissions on the parent, otherwise 403 is returned to prevent leaking information, but as discussed on #915, it also makes sense to return 404 for users with `read` permissions on the parent.


Discussion:

Resolving patch:
diff --git a/kinto/core/authorization.py b/kinto/core/authorization.py
--- a/kinto/core/authorization.py
+++ b/kinto/core/authorization.py
@@ -64,13 +64,21 @@ def permits(self, context, principals, permission):
             permission = create_permission
 
         object_id = context.permission_object_id
-        if self.get_bound_permissions is None:
-            bound_perms = [(object_id, permission)]
-        else:
-            bound_perms = self.get_bound_permissions(object_id, permission)
+        bound_perms = self._get_bound_permissions(object_id, permission)
 
         allowed = context.check_permission(principals, bound_perms)
 
+        # Here we consider that parent URI is one path level above.
+        parent_uri = '/'.join(object_id.split('/')[:-1]) if object_id else None
+
+        # If not allowed to delete/patch, and target object is missing, and
+        # allowed to read the parent, then view is permitted (will raise 404
+        # later anyway). See Kinto/kinto#918
+        is_record_unknown = not context.on_collection and context.current_record is None
+        if context.required_permission == "write" and is_record_unknown:
+            bound_perms = self._get_bound_permissions(parent_uri, "read")
+            allowed = context.check_permission(principals, bound_perms)
+
         # If not allowed on this collection, but some records are shared with
         # the current user, then authorize.
         # The ShareableResource class will take care of the filtering.
@@ -81,8 +89,6 @@ def permits(self, context, principals, permission):
                                                   self.get_bound_permissions)
             # If allowed to create this kind of object on parent, then allow to obtain the list.
             if len(bound_perms) > 0:
-                # Here we consider that parent URI is one path level above.
-                parent_uri = '/'.join(object_id.split('/')[:-1])
                 parent_create_perm = [(parent_uri, create_permission)]
             else:
                 parent_create_perm = [('', 'create')]  # Root object.
@@ -92,6 +98,11 @@ def permits(self, context, principals, permission):
 
         return allowed
 
+    def _get_bound_permissions(self, object_id, permission):
+        if self.get_bound_permissions is None:
+            return [(object_id, permission)]
+        return self.get_bound_permissions(object_id, permission)
+
     def principals_allowed_by_permission(self, context, permission):
         raise NotImplementedError()  # PRAGMA NOCOVER
 
@@ -129,6 +140,15 @@ def __init__(self, request):
             self.resource_name = request.current_resource_name
             self.on_collection = getattr(service, "type", None) == "collection"
 
+            # Try to fetch the target object. Its existence will affect permissions checking.
+            if not self.on_collection and request.method.lower() in ("put", "delete", "patch"):
+                resource = service.resource(request=request, context=self)
+                try:
+                    # Save a reference, to avoid refetching from storage in resource.
+                    self.current_record = resource.model.get_record(resource.record_id)
+                except storage_exceptions.RecordNotFoundError:
+                    pass
+
             self.permission_object_id, self.required_permission = (
                 self._find_required_permission(request, service))
 
@@ -228,13 +248,7 @@ def _find_required_permission(self, request, service):
         # In the case of a "PUT", check if the targetted record already
         # exists, return "write" if it does, "create" otherwise.
         if request.method.lower() == "put":
-            resource = service.resource(request=request, context=self)
-            try:
-                record = resource.model.get_record(resource.record_id)
-                # Save a reference, to avoid refetching from storage in
-                # resource.
-                self.current_record = record
-            except storage_exceptions.RecordNotFoundError:
+            if self.current_record is None:
                 # The record does not exist, the permission to create on
                 # the related collection is required.
                 permission_object_id = collection_path
diff --git a/kinto/core/resource/__init__.py b/kinto/core/resource/__init__.py
--- a/kinto/core/resource/__init__.py
+++ b/kinto/core/resource/__init__.py
@@ -663,7 +663,7 @@ def apply_changes(self, record, requested_changes):
             except ValueError as e:
                 error_details = {
                     'location': 'body',
-                    'description': 'JSON Patch operation failed'
+                    'description': 'JSON Patch operation failed: %s' % e
                 }
                 raise_invalid(self.request, **error_details)
 
@@ -1153,7 +1153,7 @@ def __init__(self, *args, **kwargs):
             self.model.current_principal = Everyone
         else:
             self.model.current_principal = self.request.prefixed_userid
-        self.model.effective_principals = self.request.effective_principals
+        self.model.prefixed_principals = self.request.prefixed_principals
 
         if self.context:
             self.model.get_permission_object_id = functools.partial(
diff --git a/kinto/core/resource/model.py b/kinto/core/resource/model.py
--- a/kinto/core/resource/model.py
+++ b/kinto/core/resource/model.py
@@ -244,7 +244,7 @@ def __init__(self, *args, **kwargs):
         self.get_permission_object_id = None
         # Current user main principal.
         self.current_principal = None
-        self.effective_principals = None
+        self.prefixed_principals = None
 
     def _allow_write(self, perm_object_id):
         """Helper to give the ``write`` permission to the current user.
@@ -257,7 +257,7 @@ def _annotate(self, record, perm_object_id):
         permissions = self.permission.get_object_permissions(perm_object_id)
         # Permissions are not returned if user only has read permission.
         writers = permissions.get('write', [])
-        principals = self.effective_principals + [self.current_principal]
+        principals = self.prefixed_principals + [self.current_principal]
         if len(set(writers) & set(principals)) == 0:
             permissions = {}
         # Insert the permissions values in the response.


Test patch:
diff --git a/kinto/core/testing.py b/kinto/core/testing.py
--- a/kinto/core/testing.py
+++ b/kinto/core/testing.py
@@ -37,6 +37,7 @@ def __init__(self, *args, **kwargs):
             'system.Everyone',
             'system.Authenticated',
             'bob']
+        self.prefixed_principals = self.effective_principals + [self.prefixed_userid]
         self.json = {}
         self.validated = {}
         self.matchdict = {}
diff --git a/tests/core/resource/test_object_permissions.py b/tests/core/resource/test_object_permissions.py
--- a/tests/core/resource/test_object_permissions.py
+++ b/tests/core/resource/test_object_permissions.py
@@ -71,7 +71,7 @@ def test_permissions_gives_lists_of_principals_per_ace(self):
 
     def test_permissions_are_hidden_if_user_has_only_read_permission(self):
         self.resource.model.current_principal = 'account:readonly'
-        self.resource.model.effective_principals = []
+        self.resource.model.prefixed_principals = []
         result = self.resource.get()
         self.assertEqual(result['permissions'], {})
 
diff --git a/tests/test_views_objects_permissions.py b/tests/test_views_objects_permissions.py
--- a/tests/test_views_objects_permissions.py
+++ b/tests/test_views_objects_permissions.py
@@ -249,6 +249,17 @@ def test_safe_creation_with_post_returns_403_if_only_allowed_to_read(self):
                            {'data': {'id': 'child', 'members': []}},
                            headers=self.bob_headers_safe_creation, status=403)
 
+    def test_delete_returns_404_on_unknown_if_only_allowed_to_read(self):
+        self.app.delete('/buckets/read/groups/g1',
+                        headers=self.bob_headers,
+                        status=404)
+
+    def test_patch_returns_404_on_unknown_if_only_allowed_to_read(self):
+        self.app.patch_json('/buckets/read/groups/g1',
+                            {'data': {'members': []}},
+                            headers=self.bob_headers,
+                            status=404)
+
 
 class ParentMetadataTest(PermissionsTest):
     def setUp(self):
diff --git a/tests/test_views_records.py b/tests/test_views_records.py
--- a/tests/test_views_records.py
+++ b/tests/test_views_records.py
@@ -351,6 +351,9 @@ class RecordsViewPatchTest(BaseWebTest, unittest.TestCase):
 
     def setUp(self):
         super(RecordsViewPatchTest, self).setUp()
+        self.patch_headers = self.headers.copy()
+        self.patch_headers['Content-Type'] = 'application/json-patch+json'
+
         self.app.put_json('/buckets/beers', MINIMALIST_BUCKET,
                           headers=self.headers)
         self.app.put_json('/buckets/beers/collections/barley',
@@ -367,12 +370,10 @@ def setUp(self):
         self.record_url = self._record_url % self.record['id']
 
     def test_patch_add_permissions(self):
-        headers = self.headers.copy()
-        headers['Content-Type'] = 'application/json-patch+json'
         json = [{'op': 'add', 'path': '/permissions/read/me', 'value': 'me'}]
         resp = self.app.patch_json(self.record_url,
                                    json,
-                                   headers=headers,
+                                   headers=self.patch_headers,
                                    status=200)
 
         perms = resp.json['permissions']
@@ -380,12 +381,10 @@ def test_patch_add_permissions(self):
         self.assertIn('alice', perms['read'])
 
     def test_patch_update_permissions(self):
-        headers = self.headers.copy()
-        headers['Content-Type'] = 'application/json-patch+json'
         json = [{'op': 'add', 'path': '/permissions/read', 'value': ['me']}]
         resp = self.app.patch_json(self.record_url,
                                    json,
-                                   headers=headers,
+                                   headers=self.patch_headers,
                                    status=200)
 
         perms = resp.json['permissions']
@@ -393,27 +392,22 @@ def test_patch_update_permissions(self):
         self.assertNotIn(('alice', 'bob'), perms['read'])
 
     def test_patch_remove_permissions(self):
-        headers = self.headers.copy()
-        headers['Content-Type'] = 'application/json-patch+json'
         json = [{'op': 'remove', 'path': '/permissions/read/alice'}]
         resp = self.app.patch_json(self.record_url,
                                    json,
-                                   headers=headers,
+                                   headers=self.patch_headers,
                                    status=200)
-
         perms = resp.json['permissions']
         self.assertNotIn('alice', perms['read'])
 
     def test_patch_move_permissions(self):
-        headers = self.headers.copy()
-        headers['Content-Type'] = 'application/json-patch+json'
         json = [
             {'op': 'move', 'from': '/permissions/read/alice',
                            'path': '/data/old'}
         ]
         resp = self.app.patch_json(self.record_url,
                                    json,
-                                   headers=headers,
+                                   headers=self.patch_headers,
                                    status=200)
 
         perms = resp.json['permissions']
@@ -422,10 +416,8 @@ def test_patch_move_permissions(self):
         self.assertEquals('alice', data['old'])
 
     def test_patch_raises_400_on_wrong_path(self):
-        headers = self.headers.copy()
-        headers['Content-Type'] = 'application/json-patch+json'
         json = [{'op': 'add', 'path': '/permissions/destroy/me'}]
         self.app.patch_json(self.record_url,
                             json,
-                            headers=headers,
+                            headers=self.patch_headers,
                             status=400)