Repository: Kinto/kinto

Issue:
Race condition in permission.postgresql:replace_object_permissions
Because https://github.com/Kinto/kinto/blob/6ee65b018926d56101015fd1cdf69bf8760d9af8/kinto/core/permission/postgresql/__init__.py#L393-L396 is two queries, it's possible for two queries that both update permissions to race, with the result being that both principals get permissions on the object.
Race condition in permission.postgresql:replace_object_permissions
Because https://github.com/Kinto/kinto/blob/6ee65b018926d56101015fd1cdf69bf8760d9af8/kinto/core/permission/postgresql/__init__.py#L393-L396 is two queries, it's possible for two queries that both update permissions to race, with the result being that both principals get permissions on the object.


Discussion:

Resolving patch:
diff --git a/kinto/core/permission/postgresql/__init__.py b/kinto/core/permission/postgresql/__init__.py
--- a/kinto/core/permission/postgresql/__init__.py
+++ b/kinto/core/permission/postgresql/__init__.py
@@ -362,38 +362,52 @@ def replace_object_permissions(self, object_id, permissions):
             'object_id': object_id
         }
 
-        new_perms = []
+        new_aces = []
         specified_perms = []
         for i, (perm, principals) in enumerate(permissions.items()):
             placeholders['perm_{}'.format(i)] = perm
             specified_perms.append("(:perm_{})".format(i))
             for principal in set(principals):
-                j = len(new_perms)
+                j = len(new_aces)
                 placeholders['principal_{}'.format(j)] = principal
-                new_perms.append("(:perm_{}, :principal_{})".format(i, j))
+                new_aces.append("(:perm_{}, :principal_{})".format(i, j))
 
-        delete_query = """
-        WITH specified_perms AS (
-          VALUES {specified_perms}
-        )
-        DELETE FROM access_control_entries
-         USING specified_perms
-         WHERE object_id = :object_id AND permission = column1
-        """.format(specified_perms=','.join(specified_perms))
+        if not new_aces:
+            query = """
+            WITH specified_perms AS (
+              VALUES {specified_perms}
+            )
+            DELETE FROM access_control_entries
+             USING specified_perms
+             WHERE object_id = :object_id AND permission = column1
+            """.format(specified_perms=','.join(specified_perms))
 
-        insert_query = """
-        WITH new_aces AS (
-          VALUES {new_perms}
-        )
-        INSERT INTO access_control_entries(object_id, permission, principal)
-          SELECT :object_id, column1, column2
-            FROM new_aces;
-        """.format(new_perms=','.join(new_perms))
+        else:
+            query = """
+            WITH specified_perms AS (
+              VALUES {specified_perms}
+            ),
+            delete_specified AS (
+              DELETE FROM access_control_entries
+               USING specified_perms
+               WHERE object_id = :object_id AND permission = column1
+               RETURNING object_id
+            ),
+            affected_object AS (
+              SELECT object_id FROM delete_specified
+              UNION SELECT :object_id
+            ),
+            new_aces AS (
+              VALUES {new_aces}
+            )
+            INSERT INTO access_control_entries(object_id, permission, principal)
+              SELECT DISTINCT d.object_id, n.column1, n.column2
+                FROM new_aces AS n, affected_object AS d;
+            """.format(specified_perms=','.join(specified_perms),
+                       new_aces=','.join(new_aces))
 
         with self.client.connect() as conn:
-            conn.execute(delete_query, placeholders)
-            if new_perms:
-                conn.execute(insert_query, placeholders)
+            conn.execute(query, placeholders)
 
     def delete_object_permissions(self, *object_id_list):
         if len(object_id_list) == 0:


Test patch:
diff --git a/kinto/core/permission/testing.py b/kinto/core/permission/testing.py
--- a/kinto/core/permission/testing.py
+++ b/kinto/core/permission/testing.py
@@ -432,6 +432,14 @@ def test_replace_object_permission_only_replace_given_sets(self):
             "obj:del": {"user1"}
         })
 
+    def test_replace_object_permission_supports_empty_existing_entries(self):
+        self.permission.replace_object_permissions('/url/a/id/1',
+                                                   {'write': ['user1']})
+        permissions = self.permission.get_object_permissions('/url/a/id/1')
+        self.assertDictEqual(permissions, {
+            "write": {"user1"}
+        })
+
     def test_replace_object_permission_supports_empty_input(self):
         self.permission.add_principal_to_ace('/url/a/id/1', 'write', 'user1')
         self.permission.replace_object_permissions('/url/a/id/1', {})
diff --git a/tests/functional.py b/tests/functional.py
--- a/tests/functional.py
+++ b/tests/functional.py
@@ -153,8 +153,8 @@ def test_user_shared_bucket_tutorial(self):
         resp = self.session.put(bucket_url)
         # In case of concurrent execution, it may have been created already.
         self.assertIn(resp.status_code, (200, 201))
-        record = resp.json()
-        self.assertIn('write', record['permissions'])
+        bucket = resp.json()
+        self.assertIn('write', bucket['permissions'])
 
         # Create a new collection and check for permissions
         permissions = {"record:create": ['system.Authenticated']}
@@ -163,10 +163,10 @@ def test_user_shared_bucket_tutorial(self):
             json={'permissions': permissions})
         # In case of concurrent execution, it may have been created already.
         self.assertIn(resp.status_code, (200, 201))
-        record = resp.json()
-        self.assertIn('record:create', record['permissions'])
+        collection = resp.json()
+        self.assertIn('record:create', collection['permissions'])
         self.assertIn('system.Authenticated',
-                      record['permissions']['record:create'])
+                      collection['permissions']['record:create'])
 
         # Create a new tasks for Alice
         alice_auth = ('token', 'alice-secret-%s' % uuid.uuid4())
@@ -176,9 +176,9 @@ def test_user_shared_bucket_tutorial(self):
             json={'data': alice_task},
             auth=alice_auth)
         self.assertEqual(resp.status_code, 201)
-        record = resp.json()
-        self.assertIn('write', record['permissions'])
-        alice_task_id = record['data']['id']
+        collection = resp.json()
+        self.assertIn('write', collection['permissions'])
+        alice_task_id = collection['data']['id']
 
         bob_auth = ('token', 'bob-secret-%s' % uuid.uuid4())
 
@@ -194,10 +194,10 @@ def test_user_shared_bucket_tutorial(self):
             json={'data': bob_task},
             auth=bob_auth)
         self.assertEqual(resp.status_code, 201)
-        record = resp.json()
-        self.assertIn('write', record['permissions'])
-        bob_user_id = record['permissions']['write'][0]
-        bob_task_id = record['data']['id']
+        collection = resp.json()
+        self.assertIn('write', collection['permissions'])
+        bob_user_id = collection['permissions']['write'][0]
+        bob_task_id = collection['data']['id']
 
         # Now that he has a task, he should see his.
         resp = self.session.get(collection_url, auth=bob_auth)
@@ -225,18 +225,18 @@ def test_user_shared_bucket_tutorial(self):
         mary_auth = ('token', 'mary-secret-%s' % uuid.uuid4())
         resp = self.session.get('{}/'.format(self.server_url), auth=mary_auth)
         self.assertEqual(resp.status_code, 200)
-        record = resp.json()
-        mary_user_id = record['user']['id']
+        hello = resp.json()
+        mary_user_id = hello['user']['id']
 
         # Allow group creation on bucket
         permissions = {"group:create": ['system.Authenticated']}
         resp = self.session.put(bucket_url,
                                 json={'permissions': permissions})
         self.assertEqual(resp.status_code, 200)
-        record = resp.json()
-        self.assertIn('group:create', record['permissions'])
+        bucket = resp.json()
+        self.assertIn('group:create', bucket['permissions'])
         self.assertIn('system.Authenticated',
-                      record['permissions']['group:create'])
+                      bucket['permissions']['group:create'])
 
         # Create Alice's friend group with Bob and Mary
         group_url = '{}/groups/alices-friends'.format(bucket_url)