Skip to content

Commit edbe6e3

Browse files
authored
Merge pull request #3869 from kobotoolbox/3864-permission-sorting-issue
Permission assignments bug fix
2 parents c8c3413 + 1bbd225 commit edbe6e3

File tree

3 files changed

+145
-41
lines changed

3 files changed

+145
-41
lines changed

kpi/serializers/v2/asset_permission_assignment.py

Lines changed: 27 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from dataclasses import dataclass
66
from typing import Optional
77

8+
from django.db import transaction
89
from django.contrib.auth.models import Permission, User
910
from django.urls import Resolver404
1011
from django.utils.translation import gettext as t
@@ -326,6 +327,7 @@ class PermissionAssignment:
326327
permission_codename: str
327328
partial_permissions_json: Optional[str] = None
328329

330+
@transaction.atomic
329331
def create(self, validated_data):
330332
asset = self.context['asset']
331333
user_pk_to_obj_cache = dict()
@@ -468,10 +470,10 @@ def validate(self, attrs):
468470
Validate users and permissions, and convert them from API URLs into
469471
model instances, using a minimal number of database queries
470472
"""
471-
# A dictionary for looking up usernames by API user URLs
472-
url_to_username = dict()
473-
# …for looking up codenames by API permission URLs
474-
url_to_codename = dict()
473+
# A dictionary for looking up API user URLs by username
474+
username_to_url = dict()
475+
# …for looking up by API permission URLs by codename
476+
codename_to_url = dict()
475477

476478
assignable_permissions = self.context[
477479
'asset'
@@ -497,9 +499,9 @@ def validate(self, attrs):
497499
codename = self._get_arg_from_url('codename', perm_url)
498500
if codename not in assignable_permissions:
499501
raise serializers.ValidationError(INVALID_PERMISSION_ERROR)
500-
url_to_codename[perm_url] = codename
502+
codename_to_url[codename] = perm_url
501503
username = self._get_arg_from_url('username', user_url)
502-
url_to_username[user_url] = username
504+
username_to_url[username] = user_url
503505
for partial_assignment in assignment.get('partial_permissions', []):
504506
partial_codename = self._get_arg_from_url(
505507
'codename', partial_assignment['url']
@@ -511,42 +513,28 @@ def validate(self, attrs):
511513
raise serializers.ValidationError(
512514
INVALID_PARTIAL_PERMISSION_ERROR
513515
)
514-
url_to_codename[partial_assignment['url']] = partial_codename
516+
codename_to_url[partial_codename] = partial_assignment['url']
515517

516518
# Create a dictionary of API user URLs to `User` objects
517-
urls_sorted_by_username = [
518-
url
519-
for url, username in sorted(
520-
url_to_username.items(), key=lambda item: item[1]
521-
)
522-
]
523-
url_to_user = dict(
524-
zip(
525-
urls_sorted_by_username,
526-
User.objects.only('pk', 'username')
527-
.filter(username__in=url_to_username.values())
528-
.order_by('username'),
529-
)
530-
)
531-
if len(url_to_user) != len(url_to_username):
519+
url_to_user = dict()
520+
for user in User.objects.only('pk', 'username').filter(
521+
username__in=username_to_url.keys()
522+
):
523+
url = username_to_url[user.username]
524+
url_to_user[url] = user
525+
if len(url_to_user) != len(username_to_url):
532526
raise serializers.ValidationError(INVALID_USER_ERROR)
533527

534528
# Create a dictionary of API permission URLs to `Permission` objects
535-
urls_sorted_by_codename = [
536-
url
537-
for url, codename in sorted(
538-
url_to_codename.items(), key=lambda item: item[1]
539-
)
540-
]
541-
url_to_permission = dict(
542-
zip(
543-
urls_sorted_by_codename,
544-
Permission.objects.filter(codename__in=assignable_permissions)
545-
.filter(codename__in=url_to_codename.values())
546-
.order_by('codename'),
547-
)
548-
)
549-
if len(url_to_permission) != len(url_to_codename):
529+
url_to_permission = dict()
530+
for permission in (
531+
Permission.objects.filter(codename__in=assignable_permissions)
532+
.filter(codename__in=codename_to_url.keys())
533+
.order_by('codename')
534+
):
535+
url = codename_to_url[permission.codename]
536+
url_to_permission[url] = permission
537+
if len(url_to_permission) != len(codename_to_url):
550538
# This should never happen since all codenames were found within
551539
# `assignable_permissions`
552540
raise RuntimeError(
@@ -569,11 +557,9 @@ def validate(self, attrs):
569557
list
570558
)
571559
for partial_assignment in assignment['partial_permissions']:
572-
# Convert partial permission URLs to codenames only; it's
573-
# unnecessary to instantiate objects for them
574-
partial_codename = url_to_codename[
560+
partial_codename = url_to_permission[
575561
partial_assignment['url']
576-
]
562+
].codename
577563
assignment_with_objects['partial_permissions'][
578564
partial_codename
579565
] = partial_assignment['filters']

kpi/tests/api/v2/test_api_asset_permission_assignment.py

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -589,3 +589,73 @@ def test_partial_permission_grants_implied_view_asset(self):
589589
# `someuser` should have received the implied `view_asset`
590590
# permission
591591
assert self.someuser.has_perm(PERM_VIEW_ASSET, self.asset)
592+
593+
def test_no_assignments_saved_on_error(self):
594+
595+
# Call `get_anonymous_user()` to create AnonymousUser if it does not exist
596+
get_anonymous_user()
597+
598+
# Ensure someuser and anotheruser do not have 'view_submissions' on `self.asset`
599+
self.assertFalse(self.asset.has_perm(self.someuser, PERM_VIEW_SUBMISSIONS))
600+
self.assertFalse(self.asset.has_perm(self.anotheruser, PERM_VIEW_SUBMISSIONS))
601+
602+
# Allow someuser and anotheruser to view submissions
603+
good_assignments = [
604+
{
605+
'user': 'someuser',
606+
'permission': PERM_VIEW_SUBMISSIONS,
607+
},
608+
{
609+
'user': 'anotheruser',
610+
'permission': PERM_VIEW_SUBMISSIONS,
611+
}
612+
]
613+
614+
assignments = self.translate_usernames_and_codenames_to_urls(
615+
good_assignments
616+
)
617+
bulk_endpoint = reverse(
618+
self._get_endpoint('asset-permission-assignment-bulk-assignments'),
619+
kwargs={'parent_lookup_asset': self.asset.uid}
620+
)
621+
response = self.client.post(bulk_endpoint, assignments, format='json')
622+
623+
# Everything worked as expected, someuser and anotheruser got 'view_submissions'
624+
self.assertEqual(response.status_code, status.HTTP_200_OK)
625+
self.assertTrue(self.asset.has_perm(self.someuser, PERM_VIEW_SUBMISSIONS))
626+
self.assertTrue(self.asset.has_perm(self.anotheruser, PERM_VIEW_SUBMISSIONS))
627+
628+
# but do not have respectively 'delete_submissions' and 'change_submissions'
629+
self.assertFalse(self.asset.has_perm(self.someuser, PERM_DELETE_SUBMISSIONS))
630+
self.assertFalse(self.asset.has_perm(self.anotheruser, PERM_CHANGE_SUBMISSIONS))
631+
632+
bad_assignments = [
633+
{
634+
'user': 'AnonymousUser',
635+
'permission': PERM_ADD_SUBMISSIONS, # should return a 400
636+
},
637+
{
638+
'user': 'someuser',
639+
'permission': PERM_DELETE_SUBMISSIONS,
640+
},
641+
{
642+
'user': 'anotheruser',
643+
'permission': PERM_CHANGE_SUBMISSIONS,
644+
}
645+
]
646+
assignments = self.translate_usernames_and_codenames_to_urls(
647+
bad_assignments
648+
)
649+
650+
bulk_endpoint = reverse(
651+
self._get_endpoint('asset-permission-assignment-bulk-assignments'),
652+
kwargs={'parent_lookup_asset': self.asset.uid}
653+
)
654+
response = self.client.post(bulk_endpoint, assignments, format='json')
655+
# Could not assign 'add_submissions' to anonymous user.
656+
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
657+
658+
# Ensure that someuser and anotheruser did not get any other permissions
659+
# than the one they already had, i.e.: 'view_submissions'.
660+
self.assertFalse(self.asset.has_perm(self.someuser, PERM_DELETE_SUBMISSIONS))
661+
self.assertFalse(self.asset.has_perm(self.anotheruser, PERM_CHANGE_SUBMISSIONS))

kpi/tests/test_sorting.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
# coding: utf-8
2+
from django.test import TestCase
3+
from django.contrib.auth.models import User
4+
5+
from kpi.utils.object_permission import get_anonymous_user
6+
7+
8+
class SortingTestCase(TestCase):
9+
10+
def test_different_sort_between_python_and_db(self):
11+
12+
# Ensure that `AnonymousUser` is created to include it in the list below
13+
get_anonymous_user()
14+
15+
User.objects.bulk_create([
16+
User(first_name='A', last_name='User', username='a_user'),
17+
User(first_name='Alexander', last_name='Mtembenuzeni', username='alex_Mtemb'),
18+
User(first_name='Another', last_name='User', username='anotheruser'),
19+
])
20+
21+
users = list(
22+
User.objects.filter(username__istartswith='a')
23+
.values_list('username', flat=True)
24+
.order_by('username')
25+
)
26+
27+
# The database (PostgreSQL, as of Jun, 14, 2022) seems to be case
28+
# insensitive and treats `_` after any letters.
29+
# Python is case sensitive and treats `_` before any letters.
30+
expected_database = [
31+
'alex_Mtemb',
32+
'AnonymousUser',
33+
'anotheruser',
34+
'a_user',
35+
]
36+
37+
expected_python = [
38+
'AnonymousUser',
39+
'a_user',
40+
'alex_Mtemb',
41+
'anotheruser',
42+
]
43+
44+
self.assertEqual(users, expected_database)
45+
self.assertEqual(sorted(users), expected_python)
46+
# Obviously if the first two assertions are True, the one below should
47+
# be false. No matter what, let's be paranoid and test it anyway.
48+
self.assertNotEqual(users, sorted(users))

0 commit comments

Comments
 (0)