diff --git a/django/contrib/admin/options.py b/django/contrib/admin/options.py
index e5a856e1327c..1772638748c3 100644
--- a/django/contrib/admin/options.py
+++ b/django/contrib/admin/options.py
@@ -2060,13 +2060,8 @@ def _changeform_view(self, request, object_id, form_url, extra_context):
else:
obj = self.get_object(request, unquote(object_id), to_field)
-
- if request.method == "POST":
- if not self.has_change_permission(request, obj):
- raise PermissionDenied
- else:
- if not self.has_view_or_change_permission(request, obj):
- raise PermissionDenied
+ if not self.has_view_or_change_permission(request, obj):
+ raise PermissionDenied
if obj is None:
return self._get_obj_does_not_exist_redirect(
@@ -2105,6 +2100,9 @@ def _changeform_view(self, request, object_id, form_url, extra_context):
and "_continue" not in request.POST
and "_addanother" not in request.POST
):
+ selected = request.POST.getlist(helpers.ACTION_CHECKBOX_NAME)
+ if len(selected) != 1 or selected[0] != str(obj.pk):
+ raise BadRequest
queryset = self.model._default_manager.get_queryset()
if response := self.response_action(
request, queryset, action_location=ActionLocation.CHANGE_FORM
@@ -2112,6 +2110,9 @@ def _changeform_view(self, request, object_id, form_url, extra_context):
return response
return HttpResponseRedirect(request.get_full_path())
+ if not add and not self.has_change_permission(request, obj):
+ raise PermissionDenied
+
form = ModelForm(request.POST, request.FILES, instance=obj)
formsets, inline_instances = self._create_formsets(
request,
diff --git a/django/contrib/admin/templates/admin/change_form.html b/django/contrib/admin/templates/admin/change_form.html
index f7dac55e0d9f..988e92e163b8 100644
--- a/django/contrib/admin/templates/admin/change_form.html
+++ b/django/contrib/admin/templates/admin/change_form.html
@@ -43,7 +43,7 @@
{% if action_form and not is_popup %}
{% change_form_admin_actions %}
-
+
{% endif %}
{% if is_popup %}{% endif %}
{% if to_field %}{% endif %}
diff --git a/tests/admin_views/test_actions.py b/tests/admin_views/test_actions.py
index 32bd1266dcb1..dbed3a33b6f0 100644
--- a/tests/admin_views/test_actions.py
+++ b/tests/admin_views/test_actions.py
@@ -514,7 +514,10 @@ def test_popup_template_escaping(self):
self.assertIn(r""obj\\"", output)
-@override_settings(ROOT_URLCONF="admin_views.urls")
+@override_settings(
+ ROOT_URLCONF="admin_views.urls",
+ MAILERS={"default": {"BACKEND": "django.core.mail.backends.locmem.EmailBackend"}},
+)
class AdminActionsPermissionTests(TestCase):
@classmethod
def setUpTestData(cls):
@@ -568,6 +571,32 @@ def test_model_admin_no_delete_permission_externalsubscriber(self):
)
self.assertEqual(response.status_code, 403)
+ def test_minimum_permission(self):
+ permission = Permission.objects.get(codename="view_externalsubscriber")
+ self.user.user_permissions.add(permission)
+ es = ExternalSubscriber.objects.create(
+ name="John Doe", email="john@example.org"
+ )
+
+ cl_url = reverse("admin:admin_views_externalsubscriber_changelist")
+ cl_post = {ACTION_CHECKBOX_NAME: [es.pk], "action": "external_mail"}
+ cf_url = reverse("admin:admin_views_externalsubscriber_change", args=[es.pk])
+ cf_post = {ACTION_CHECKBOX_NAME: [es.pk], "CHANGE_FORM-action": "external_mail"}
+
+ for url, post_data in [(cl_url, cl_post), (cf_url, cf_post)]:
+ mail.outbox = []
+ with self.subTest(url=url):
+ response = self.client.get(url)
+ self.assertContains(
+ response,
+ '",
+ html=True,
+ )
+ response = self.client.post(url, post_data)
+ self.assertEqual(len(mail.outbox), 1)
+ self.assertEqual(response.status_code, 302)
+
@override_settings(
ROOT_URLCONF="admin_views.urls",
@@ -673,6 +702,30 @@ def test_run_action_permissions(self):
self.assertEqual(response.status_code, 200)
self.assertEqual(response.content, b"OK")
+ def test_action_changeform_cannot_target_different_objects(self):
+ changeform_url = reverse(
+ "admin:admin_views_externalsubscriber_change", args=[self.s1.pk]
+ )
+ external_subscriber = ExternalSubscriber.objects.create(
+ name="Jane Austen", email="jane@example.org"
+ )
+ for invalid_checkbox_value in [
+ [external_subscriber.pk],
+ [self.s1.pk, external_subscriber.pk],
+ [],
+ ]:
+ with self.subTest(invalid_checkbox_value=invalid_checkbox_value):
+ response = self.client.post(
+ changeform_url,
+ {
+ "CHANGE_FORM-action": "external_mail",
+ ACTION_CHECKBOX_NAME: invalid_checkbox_value,
+ "index": 0,
+ },
+ )
+ self.assertEqual(len(mail.outbox), 0)
+ self.assertEqual(response.status_code, 400)
+
def test_select_across_ignored(self):
ExternalSubscriber.objects.create(name="Bob Bobson", email="bob@example.org")
change_url = reverse(