Skip to content

fix: use the permission's message for denial responses - #91

Open
mmohajer9 wants to merge 1 commit into
em1208:mainfrom
mmohajer9:fix/permission-denied-message
Open

fix: use the permission's message for denial responses#91
mmohajer9 wants to merge 1 commit into
em1208:mainfrom
mmohajer9:fix/permission-denied-message

Conversation

@mmohajer9

Copy link
Copy Markdown

Problem

Permission denials on adrf.views.APIView always render DRF's generic "You do not have permission to perform this action.", silently discarding any custom message set on the permission class. The equivalent sync DRF view reports the real reason, so moving a viewset to adrf quietly degrades every custom denial message.

There are two distinct causes, spread across the four leaf checks in adrf/views.py:

1. Wrong attribute name (all four methods). They read getattr(permission, "detail", None), but DRF permission classes carry the denial text on .message:

class BasePermission(metaclass=BasePermissionMetaclass):
    message = None

and rest_framework.views.APIView.check_permissions reads it accordingly:

self.permission_denied(
    request,
    message=getattr(permission, 'message', None),
    code=getattr(permission, 'code', None)
)

.detail is the attribute on APIException, not on permissions, so the getattr always fell through to None.

2. Wrong object (async methods only). check_async_permissions and check_async_object_permissions read the attribute off has_permission — the boolean result of asyncio.gather — rather than off the permission instance:

for has_permission in has_permissions:
    ...
    elif not has_permission:
        self.permission_denied(
            request,
            message=getattr(has_permission, "detail", None),  # getattr(False, ...) -> None
            code=getattr(has_permission, "code", None),
        )

Since the loop had already discarded the permission objects, even the correct attribute name could not have resolved here. code was affected the same way.

Fix

Read message/code from the permission object in all four methods, and zip the gathered results back with their permissions in the two async ones. This makes the behaviour identical to rest_framework.views.APIView.check_permissions.

Why this wasn't caught

tests/test_permissions.py and tests/test_object_permissions.py only assert status_code == 403 and never inspect the response body, so a denial with the wrong message passes.

This PR adds tests asserting the resulting detail and its code for all four paths — sync, async, object and non-object — plus one that verifies the message comes from the permission that actually returned False when an allowing permission precedes it (which is what the missing zip broke).

Note the new test views set authentication_classes = (): permission_denied short circuits to NotAuthenticated when the request carries authenticators but none succeeded, which would otherwise mask the message under test.

Verification

134 passed

(129 before this change, 5 new.) All 5 new tests fail on main with the generic message and pass with the fix. ruff check and ruff format --check are clean under the pinned v0.5.5 from .pre-commit-config.yaml.

Compatibility

No public API change. A permission that never set .message keeps producing the generic default. Projects that had worked around this by setting .detail on a permission class would lose that workaround — but that attribute was never read by DRF itself and only ever resolved to None in the async paths here.

Permission denials on `adrf.views.APIView` always fell back to DRF's
generic "You do not have permission to perform this action.", discarding
any custom `message` set on the permission class.
Two separate causes, across the four leaf checks:
1. All four read `permission.detail`, but DRF permission classes carry
   the denial text on `.message` (see `BasePermission.message` and
   `rest_framework.views.APIView.check_permissions`). `.detail` is the
   attribute on `APIException`, not on permissions, so the getattr always
   returned `None`.
2. `check_async_permissions` and `check_async_object_permissions`
   additionally read the attribute off the *boolean result* of
   `asyncio.gather` rather than off the permission object, so even the
   correct attribute name could never have resolved. The results are now
   zipped back with their permissions.
The `code` attribute was affected by (2) in the same way.
This makes async views report the same denial reason as the equivalent
sync DRF view. Existing tests only asserted `status_code == 403` and
never inspected the response body, which is how this went unnoticed;
the added tests assert the message and code for the sync, async, object
and non-object paths.
@mmohajer9
mmohajer9 force-pushed the fix/permission-denied-message branch from bcd2b15 to 46746fc Compare August 7, 2026 18:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant