Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 36 additions & 1 deletion django_admin_react/api/object_actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,11 +73,46 @@ def permitted_action_names(

declared = getattr(model_admin, "change_actions", None)
if declared is not None:
return [str(n) for n in (declared or ())]
# Defense in depth (#455): in the fallback path (no
# ``get_change_actions`` hook) filter each declared name by its
# action callable's ``allowed_permissions`` against the admin's
# ``has_<perm>_permission(request)``, mirroring Django's own
# ``_filter_actions_by_permissions``. The run view still gates the
# object via ``has_change_permission``; this just keeps an action
# the user can't run from being surfaced.
out: list[str] = []
for raw in declared or ():
name = str(raw)
callable_obj = getattr(model_admin, name, None)
perms = getattr(callable_obj, "allowed_permissions", None) or ()
if _user_has_action_perms(model_admin, request, perms):
out.append(name)
return out

return None


def _user_has_action_perms(
model_admin: ModelAdmin, request: HttpRequest, perms: Any
) -> bool:
"""Apply each ``perm`` against ``has_<perm>_permission(request)``.

Mirrors Django's ``_filter_actions_by_permissions``. An unknown perm
(no ``has_<perm>_permission`` method) or a raising check denies — we
never grant an action whose permission we can't verify.
"""
for perm in perms:
method = getattr(model_admin, f"has_{perm}_permission", None)
if not callable(method):
return False
try:
if not method(request):
return False
except Exception:
return False
return True


def resolve_action_callable(model_admin: ModelAdmin, name: str) -> Any | None:
"""Resolve one object-action name to its bound callable, or ``None``.

Expand Down
28 changes: 28 additions & 0 deletions tests/test_object_actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,34 @@ def test_detail_change_actions_attr_without_hook(superuser_client: Client) -> No
assert {a["name"] for a in body["object_actions"]} == {"_deactivate"}


@pytest.mark.django_db
def test_detail_fallback_filters_by_allowed_permissions(superuser_client: Client) -> None:
"""Defense in depth (#455): without ``get_change_actions``, the
``change_actions`` fallback also filters each declared action by its
callable's ``allowed_permissions`` against ``has_<perm>_permission``,
mirroring Django's ``_filter_actions_by_permissions``. An action whose
required perm fails is dropped from ``object_actions``."""
u = _make_user()

def _delete_action(self, request, queryset): # noqa: ANN001, ANN202, ARG001
queryset.delete()

_delete_action.allowed_permissions = ("delete",)

def _no_delete(self, request, obj=None): # noqa: ANN001, ANN202, ARG001
return False

with admin_attr(
User,
change_actions=["_delete_action"],
_delete_action=_delete_action,
has_delete_permission=_no_delete,
):
body = superuser_client.get(DETAIL_URL.format(pk=u.pk)).json()
# _delete_action requires `delete`; the admin denies → action dropped.
assert body["object_actions"] == []


@pytest.mark.django_db
def test_detail_filters_unpermitted_actions(superuser_client: Client) -> None:
"""An action the user is not permitted to run (per get_change_actions)
Expand Down
Loading