Skip to content

Commit 642a65d

Browse files
author
Stefan Majoor
committed
Fix an issue where a view scope that depended on a nullable foreign key would trigger a SQL error when doing a PUT / Delete request
1 parent bb2c8d4 commit 642a65d

File tree

3 files changed

+82
-6
lines changed

3 files changed

+82
-6
lines changed

binder/views.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2717,7 +2717,13 @@ def put(self, request, pk=None):
27172717
values = self._get_request_values(request)
27182718

27192719
try:
2720-
obj = self.get_queryset(request).select_for_update().get(pk=int(pk))
2720+
# Step one does the permission check. We cannot do a select for update here, since the queryeset
2721+
# of get_queryset can potentially have outer joins on nullable values
2722+
obj = self.get_queryset(request).get(pk=int(pk))
2723+
2724+
# Now that we know we have access to this moel, we can get it again, this time with lock.
2725+
obj = self.model.objects.select_for_update().get(pk=obj.pk)
2726+
27212727
# Permission checks are done at this point, so we can avoid get_queryset()
27222728
include_annotations = self._parse_include_annotations(request)
27232729
annotations = include_annotations.get('')
@@ -2799,10 +2805,14 @@ def delete(self, request, pk=None, undelete=False, skip_body_check=False):
27992805
except ValueError:
28002806
pass
28012807

2808+
# This make sure we do all permission checks. We cannot do a select for update here, since there is a possiblity
2809+
# that we create a queryset that we cannot use in a for select clause.
28022810
try:
2803-
obj = self.get_queryset(request).select_for_update().get(pk=int(pk))
2811+
obj = self.get_queryset(request).get(pk=int(pk))
28042812
except ObjectDoesNotExist:
28052813
raise BinderNotFound()
2814+
# We now retrieve the model again, without the permission checks, which we already this.
2815+
obj = self.model.objects.select_for_update().get(pk=obj.pk)
28062816

28072817
self.delete_obj(obj, undelete, request)
28082818
logger.info('{}DELETEd {} #{}'.format('UN' if undelete else '', self._model_name(), pk))

tests/test_permission_view.py

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from django.db.models import Q
77

88
from binder.json import jsonloads, jsondumps
9-
from binder.permissions.views import is_q_stricter, smart_q_or
9+
from binder.permissions.views import is_q_stricter, smart_q_or, PermissionView
1010

1111
from .testapp.models import Zoo, ZooEmployee, Country, City, PermanentCity, CityState, Animal
1212
from .testapp.urls import router
@@ -804,3 +804,60 @@ def test_pk_not_in_empty_list_top_level_or(self):
804804

805805
combined = smart_q_or(pk_not_in_empty_list | foo)
806806
self.assertEqual(combined, pk_not_in_empty_list)
807+
808+
class ForUpdateErrorNotOccurringTest(TestCase):
809+
"""
810+
Previously there was a bug where if you did a delete on an object in a permisionview, while at the same time you
811+
also had a view scope that depended on a nullable field, then a SQL error would occur during the deletion:
812+
813+
814+
"FOR UPDATE cannot be applied to the nullable side of an outer join"
815+
816+
"""
817+
def setUp(self):
818+
super().setUp()
819+
820+
group = Group.objects.get()
821+
822+
u = User(username='testuser', is_active=True, is_superuser=False)
823+
u.set_password('test')
824+
u.save()
825+
u.groups.add(group)
826+
827+
self.client = Client()
828+
r = self.client.login(username='testuser', password='test')
829+
self.assertTrue(r)
830+
831+
@override_settings(BINDER_PERMISSION={
832+
'testapp.view_country': [
833+
('testapp.view_country', 'all'),
834+
('testapp.view_citystate', 'netherlands'),
835+
('testapp.delete_citystate', 'all'),
836+
],
837+
})
838+
def test_for_update_bug_not_occurs_on_deletion(self):
839+
country = Country.objects.create(name='Netherlands')
840+
city_state = CityState.objects.create(name='Luxembourg', country=country)
841+
842+
res = self.client.delete(f'/city_state/{city_state.id}/')
843+
844+
self.assertEqual(204, res.status_code)
845+
846+
@override_settings(BINDER_PERMISSION={
847+
'testapp.view_country': [
848+
('testapp.view_country', 'all'),
849+
('testapp.view_citystate', 'netherlands'),
850+
('testapp.delete_citystate', 'all'),
851+
('testapp.change_citystate', 'all'),
852+
],
853+
})
854+
def test_for_update_bug_not_occurs_on_put(self):
855+
# Same bug occurred with a put request
856+
country = Country.objects.create(name='Netherlands')
857+
city_state = CityState.objects.create(name='Luxembourg', country=country)
858+
859+
res = self.client.put(f'/city_state/{city_state.id}/', data=jsondumps({
860+
}))
861+
print(res.json())
862+
863+
self.assertEqual(200, res.status_code)

tests/testapp/views/city.py

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,23 @@
1+
from django.db.models import Q
2+
13
from binder.permissions.views import PermissionView
24
from ..models.city import CityState, City, PermanentCity
35

46

57
class CityView(PermissionView):
6-
model = City
8+
model = City
79

810

911
class CityStateView(PermissionView):
10-
model = CityState
12+
model = CityState
13+
14+
def _scope_view_netherlands(self, request):
15+
"""
16+
This is a bit of a weird edge case, but using this type of Q object creates an outer join with
17+
nullable side, thus preventhing us from doing a "select FOR UPDATE" clause
18+
"""
19+
return Q(country__name='Netherlands') | Q(name='Luxembourg')
1120

1221

1322
class PermanentCityView(PermissionView):
14-
model = PermanentCity
23+
model = PermanentCity

0 commit comments

Comments
 (0)