Skip to content

Commit b0afbc6

Browse files
committed
[fix] Tighten ReadOnlyAdmin delete permissions
1 parent ff30fac commit b0afbc6

2 files changed

Lines changed: 60 additions & 12 deletions

File tree

openwisp_utils/admin.py

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,13 +37,24 @@ def has_delete_permission(self, request, obj=None):
3737
opts = self.model._meta
3838
resolver_match = getattr(request, "resolver_match", None)
3939
url_name = getattr(resolver_match, "url_name", None)
40-
if url_name and url_name in (
40+
own_admin_urls = (
4141
f"{opts.app_label}_{opts.model_name}_delete",
4242
f"{opts.app_label}_{opts.model_name}_change",
4343
f"{opts.app_label}_{opts.model_name}_changelist",
44-
):
44+
)
45+
if url_name in own_admin_urls:
4546
return False
46-
return super().has_delete_permission(request, obj)
47+
# Django calls child admins during parent delete confirmations;
48+
# allow only those cascade checks to use normal delete permissions.
49+
is_parent_delete = url_name and url_name.endswith("_delete")
50+
is_parent_bulk_delete = (
51+
url_name
52+
and url_name.endswith("_changelist")
53+
and request.POST.get("action") == "delete_selected"
54+
)
55+
if is_parent_delete or is_parent_bulk_delete:
56+
return super().has_delete_permission(request, obj)
57+
return False
4758

4859
def save_model(self, request, obj, form, change): # pragma: nocover
4960
pass

tests/test_project/tests/test_admin.py

Lines changed: 46 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
from unittest.mock import MagicMock, patch
22

3+
from django.contrib import admin
34
from django.contrib.admin.sites import AdminSite
4-
from django.contrib.auth.models import Permission, User
5+
from django.contrib.auth import get_user_model
6+
from django.contrib.auth.models import Permission
57
from django.core.exceptions import ImproperlyConfigured
68
from django.test import TestCase
79
from django.urls import reverse
@@ -21,6 +23,8 @@
2123
)
2224
from . import AdminTestMixin, CreateMixin
2325

26+
User = get_user_model()
27+
2428

2529
class TestAdmin(AdminTestMixin, CreateMixin, TestCase):
2630
TEST_KEY = "w1gwJxKaHcamUw62TQIPgYchwLKn3AA0"
@@ -118,6 +122,8 @@ class TestReadOnlyAdmin(ReadOnlyAdmin):
118122

119123
def test_readonlyadmin_has_delete_permission(self):
120124
modeladmin = ReadOnlyAdmin(RadiusAccounting, AdminSite())
125+
# The Django test client keeps the resolved request on the response;
126+
# these assertions call the admin permission method directly.
121127

122128
with self.subTest("changelist URL returns False"):
123129
request = self.client.get(
@@ -139,23 +145,32 @@ def test_readonlyadmin_has_delete_permission(self):
139145
).wsgi_request
140146
self.assertFalse(modeladmin.has_delete_permission(request))
141147

142-
with self.subTest("cascade delete from unrelated URL returns True"):
143-
# Simulate being called from a parent model's delete
144-
# confirmation (cascade), not from the model's own views.
148+
with self.subTest("cascade delete from parent delete URL returns True"):
145149
request = self.client.get(
146150
reverse("admin:test_project_radiusaccounting_changelist")
147151
).wsgi_request
148152
mock_resolver = MagicMock()
149-
mock_resolver.url_name = "index"
153+
mock_resolver.url_name = "test_project_project_delete"
150154
request.resolver_match = mock_resolver
151155
self.assertTrue(modeladmin.has_delete_permission(request))
152156

153-
with self.subTest("no resolver_match returns True"):
157+
with self.subTest("parent bulk delete returns True"):
158+
request = self.client.post(
159+
reverse("admin:test_project_project_changelist"),
160+
data={"action": "delete_selected"},
161+
).wsgi_request
162+
self.assertTrue(modeladmin.has_delete_permission(request))
163+
164+
with self.subTest("unrelated admin URL returns False"):
165+
request = self.client.get(reverse("admin:index")).wsgi_request
166+
self.assertFalse(modeladmin.has_delete_permission(request))
167+
168+
with self.subTest("no resolver_match returns False"):
154169
request = self.client.get(
155170
reverse("admin:test_project_radiusaccounting_changelist")
156171
).wsgi_request
157172
request.resolver_match = None
158-
self.assertTrue(modeladmin.has_delete_permission(request))
173+
self.assertFalse(modeladmin.has_delete_permission(request))
159174

160175
with self.subTest("cascade delete without child permission returns False"):
161176
user = User.objects.create(
@@ -166,12 +181,34 @@ def test_readonlyadmin_has_delete_permission(self):
166181
)
167182
self.client.force_login(user)
168183
request = self.client.get(reverse("admin:index")).wsgi_request
169-
170184
mock_resolver = MagicMock()
171-
mock_resolver.url_name = "index"
185+
mock_resolver.url_name = "test_project_project_delete"
172186
request.resolver_match = mock_resolver
173187
self.assertFalse(modeladmin.has_delete_permission(request))
174188

189+
def test_readonlyadmin_allows_parent_cascade_delete(self):
190+
original_admin = admin.site._registry[Operator].__class__
191+
admin.site.unregister(Operator)
192+
admin.site.register(Operator, ReadOnlyAdmin)
193+
try:
194+
project = Project.objects.create(name="test-parent-delete")
195+
operator = Operator.objects.create(
196+
first_name="Jane", last_name="Doe", project=project
197+
)
198+
path = reverse("admin:test_project_project_delete", args=[project.pk])
199+
response = self.client.get(path)
200+
self.assertEqual(response.status_code, 200)
201+
self.assertNotContains(
202+
response, "your account doesn't have permission to delete"
203+
)
204+
response = self.client.post(path, data={"post": "yes"}, follow=True)
205+
self.assertEqual(response.status_code, 200)
206+
self.assertFalse(Project.objects.filter(pk=project.pk).exists())
207+
self.assertFalse(Operator.objects.filter(pk=operator.pk).exists())
208+
finally:
209+
admin.site.unregister(Operator)
210+
admin.site.register(Operator, original_admin)
211+
175212
def test_context_processor(self):
176213
url = reverse("admin:index")
177214
response = self.client.get(url)

0 commit comments

Comments
 (0)