Repository: Kinto/kinto

Issue:
Delete matching records from another user's data
Set up two users (`user:pass1` and `user:pass2`) with data in matching locations `collection-1/record-1`:

```
> http -v -j --auth 'user:pass1' PUT localhost:8888/v1/buckets/default/collections/collection-1/records/record-1 data:='{"flavor": "banana"}'
PUT /v1/buckets/default/collections/collection-1/records/record-1 HTTP/1.1
Accept: application/json
Accept-Encoding: gzip, deflate
Authorization: Basic dXNlcjpwYXNzMQ==
Connection: keep-alive
Content-Length: 30
Content-Type: application/json
Host: localhost:8888
User-Agent: HTTPie/0.9.4

{
    "data": {
        "flavor": "banana"
    }
}

HTTP/1.1 201 Created
Access-Control-Expose-Headers: Content-Length, Backoff, Retry-After, Alert
Content-Length: 177
Content-Type: application/json
Date: Wed, 03 May 2017 15:53:56 GMT
Etag: "1493826836329"
Last-Modified: Wed, 03 May 2017 15:53:56 GMT
Server: waitress
X-Content-Type-Options: nosniff

{
    "data": {
        "flavor": "banana",
        "id": "record-1",
        "last_modified": 1493826836329
    },
    "permissions": {
        "write": [
            "basicauth:9914feaa0d3c0c09afd3bba1f0f1932e6c7ac92b5b18c89d744d85209eb0061f"
        ]
    }
}

> http -v -j --auth 'user:pass2' PUT localhost:8888/v1/buckets/default/collections/collection-1/records/record-1 data:='{"flavor": "strawberry"}'
PUT /v1/buckets/default/collections/collection-1/records/record-1 HTTP/1.1
Accept: application/json
Accept-Encoding: gzip, deflate
Authorization: Basic dXNlcjpwYXNzMg==
Connection: keep-alive
Content-Length: 34
Content-Type: application/json
Host: localhost:8888
User-Agent: HTTPie/0.9.4

{
    "data": {
        "flavor": "strawberry"
    }
}

HTTP/1.1 201 Created
Access-Control-Expose-Headers: Content-Length, Backoff, Retry-After, Alert
Content-Length: 181
Content-Type: application/json
Date: Wed, 03 May 2017 15:54:05 GMT
Etag: "1493826845879"
Last-Modified: Wed, 03 May 2017 15:54:05 GMT
Server: waitress
X-Content-Type-Options: nosniff

{
    "data": {
        "flavor": "strawberry",
        "id": "record-1",
        "last_modified": 1493826845879
    },
    "permissions": {
        "write": [
            "basicauth:db446a4e0ffd571c291d2d7e20bfd978a1e92cd5fe566ca796af74c40943c583"
        ]
    }
}
```

`user:pass1` deletes their bucket:

```
> http -v -j --auth 'user:pass1' DELETE localhost:8888/v1/buckets/default
DELETE /v1/buckets/default HTTP/1.1
Accept: application/json
Accept-Encoding: gzip, deflate
Authorization: Basic dXNlcjpwYXNzMQ==
Connection: keep-alive
Content-Length: 0
Content-Type: application/json
Host: localhost:8888
User-Agent: HTTPie/0.9.4



HTTP/1.1 200 OK
Access-Control-Expose-Headers: Content-Length, Backoff, Retry-After, Alert
Content-Length: 99
Content-Type: application/json
Date: Wed, 03 May 2017 15:54:19 GMT
Etag: "1493826859249"
Last-Modified: Wed, 03 May 2017 15:54:19 GMT
Server: waitress
X-Content-Type-Options: nosniff

{
    "data": {
        "deleted": true,
        "id": "dd5b2cab-2a93-8d97-ea47-b5ad69e5c9aa",
        "last_modified": 1493826859249
    }
}
```

`user:pass2` checks their data, but it's gone.

```
> http -v -j --auth 'user:pass2' localhost:8888/v1/buckets/default/collections
GET /v1/buckets/default/collections HTTP/1.1
Accept: application/json
Accept-Encoding: gzip, deflate
Authorization: Basic dXNlcjpwYXNzMg==
Connection: keep-alive
Content-Type: application/json
Host: localhost:8888
User-Agent: HTTPie/0.9.4



HTTP/1.1 200 OK
Access-Control-Expose-Headers: Total-Records, Next-Page, Backoff, Retry-After, Cache-Control, Last-Modified, ETag, Expires, Content-Length, Pragma, Alert
Cache-Control: no-cache, no-store
Content-Length: 11
Content-Type: application/json
Date: Wed, 03 May 2017 15:54:27 GMT
Etag: "1493826845871"
Last-Modified: Wed, 03 May 2017 15:54:05 GMT
Server: waitress
Total-Records: 0
X-Content-Type-Options: nosniff

{
    "data": []
}
```

The problem is that the `where` clause in https://github.com/Kinto/kinto/blob/13169807b3230d76e1d1c241ec3118a9e58d0e32/kinto/core/storage/postgresql/__init__.py#L420-L428 and https://github.com/Kinto/kinto/blob/13169807b3230d76e1d1c241ec3118a9e58d0e32/kinto/core/storage/postgresql/__init__.py#L439-L446 matches only on IDs, whereas the actual primary key for the table is `(id, parent_id, collection_id)`.


Discussion:

Resolving patch:
diff --git a/kinto/core/permission/__init__.py b/kinto/core/permission/__init__.py
--- a/kinto/core/permission/__init__.py
+++ b/kinto/core/permission/__init__.py
@@ -153,10 +153,21 @@ def get_objects_permissions(self, objects_ids, permissions=None):
         raise NotImplementedError
 
     def replace_object_permissions(self, object_id, permissions):
-        """Replace given object permissions.
+        """Update the set of principals allowed to perform some actions on an
+        object.
+
+        The only update of permissions allowed by Kinto's API is
+        complete replacement of some permission (e.g. I don't know who
+        was previously allowed to read this object, but now it's Joe,
+        Frank, and Paul). This method implements that, completely
+        replacing the set of principals who are granted the given
+        permissions (while leaving other permissions alone).
+
+        :param str object_id: The object to replace permissions on.
+        :param permissions: A dict of perm -> principals (where
+        principals is an iterable of individuals) to be granted on
+        this object.
 
-        :param str object_id: The object to replace permissions to.
-        :param str permissions: The permissions dict to replace.
         """
         raise NotImplementedError
 
diff --git a/kinto/core/storage/postgresql/__init__.py b/kinto/core/storage/postgresql/__init__.py
--- a/kinto/core/storage/postgresql/__init__.py
+++ b/kinto/core/storage/postgresql/__init__.py
@@ -415,17 +415,23 @@ def delete_all(self, collection_id, parent_id, filters=None,
         if with_deleted:
             query = """
             WITH deleted_records AS (
+                WITH matching_records AS (
+                    SELECT id, parent_id, collection_id
+                        FROM records
+                        WHERE {parent_id_filter}
+                              {collection_id_filter}
+                              {conditions_filter}
+                              {pagination_rules}
+                        {sorting}
+                        {pagination_limit}
+                )
                 DELETE
                 FROM records
-                WHERE id IN (SELECT id
-                             FROM records
-                             WHERE {parent_id_filter}
-                                   {collection_id_filter}
-                                   {conditions_filter}
-                                   {pagination_rules}
-                             {sorting}
-                             {pagination_limit})
-                RETURNING id, parent_id, collection_id
+                USING matching_records
+                WHERE records.id = matching_records.id AND
+                      records.parent_id = matching_records.parent_id AND
+                      records.collection_id = matching_records.collection_id
+                RETURNING records.id, records.parent_id, records.collection_id
             )
             INSERT INTO deleted (id, parent_id, collection_id)
             SELECT id, parent_id, collection_id
@@ -434,17 +440,23 @@ def delete_all(self, collection_id, parent_id, filters=None,
             """
         else:
             query = """
+            WITH matching_records AS (
+                SELECT id, parent_id, collection_id
+                    FROM records
+                    WHERE {parent_id_filter}
+                          {collection_id_filter}
+                          {conditions_filter}
+                          {pagination_rules}
+                    {sorting}
+                    {pagination_limit}
+            )
             DELETE
             FROM records
-            WHERE id IN (SELECT id
-                         FROM records
-                         WHERE {parent_id_filter}
-                               {collection_id_filter}
-                               {conditions_filter}
-                               {pagination_rules}
-                         {sorting}
-                         {pagination_limit})
-            RETURNING id, as_epoch(last_modified) AS last_modified;
+            USING matching_records
+            WHERE records.id = matching_records.id AND
+                  records.parent_id = matching_records.parent_id AND
+                  records.collection_id = matching_records.collection_id
+            RETURNING records.id, as_epoch(last_modified) AS last_modified;
             """
 
         id_field = id_field or self.id_field


Test patch:
diff --git a/kinto/core/storage/testing.py b/kinto/core/storage/testing.py
--- a/kinto/core/storage/testing.py
+++ b/kinto/core/storage/testing.py
@@ -894,6 +894,18 @@ def test_delete_all_does_proper_parent_id_matching(self):
         self.assertEqual(count, 1)
         self.assertEqual(len(records), 1)
 
+    def test_delete_all_does_proper_matching(self):
+        self.create_record(parent_id='abc', collection_id='c', record={"id": "id1"})
+        self.create_record(parent_id='def', collection_id='g', record={"id": "id1"})
+        self.storage.delete_all(parent_id='ab*',
+                                collection_id=None,
+                                with_deleted=False)
+        records, count = self.storage.get_all(parent_id='def',
+                                              collection_id='g',
+                                              include_deleted=True)
+        self.assertEqual(count, 1)
+        self.assertEqual(len(records), 1)
+
     def test_delete_all_can_delete_by_parent_id_with_tombstones(self):
         self.create_record(parent_id='abc', collection_id='c')
         self.create_record(parent_id='abc', collection_id='c')