Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add mixins for CBV and DRF that check permissions automatically
- Loading branch information
Robert Schindler
committed
Aug 7, 2019
1 parent
bb09e04
commit ca9a984
Showing
6 changed files
with
426 additions
and
51 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,77 @@ | ||
from django.core.exceptions import ImproperlyConfigured, PermissionDenied | ||
|
||
|
||
class AutoPermissionViewSetMixin: | ||
""" | ||
Enforces object-level permissions in ``rest_framework.viewsets.ViewSet``, | ||
deriving the permission type from the particular action to be performed.. | ||
As with ``rules.contrib.views.AutoPermissionRequiredMixin``, this only works when | ||
model permissions are registered using ``rules.contrib.models.RulesModelMixin``. | ||
""" | ||
|
||
# Maps API actions to model permission types. None as value skips permission | ||
# checks for the particular action. | ||
# This map needs to be extended when custom actions are implemented | ||
# using the @action decorator. | ||
# Extend or replace it in subclasses like so: | ||
# permission_type_map = { | ||
# **AutoPermissionViewSetMixin.permission_type_map, | ||
# "close": "change", | ||
# "reopen": "change", | ||
# } | ||
permission_type_map = { | ||
"create": "add", | ||
"destroy": "delete", | ||
"list": None, | ||
"partial_update": "change", | ||
"retrieve": "view", | ||
"update": "change", | ||
} | ||
|
||
def initial(self, *args, **kwargs): | ||
"""Ensures user has permission to perform the requested action.""" | ||
super().initial(*args, **kwargs) | ||
|
||
if not self.request.user: | ||
# No user, don't check permission | ||
return | ||
|
||
# Get the handler for the HTTP method in use | ||
try: | ||
if self.request.method.lower() not in self.http_method_names: | ||
raise AttributeError | ||
handler = getattr(self, self.request.method.lower()) | ||
except AttributeError: | ||
# method not supported, will be denied anyway | ||
return | ||
|
||
try: | ||
perm_type = self.permission_type_map[self.action] | ||
except KeyError: | ||
raise ImproperlyConfigured( | ||
"AutoPermissionViewSetMixin tried to check permissions for a " | ||
"request with the {!r} action, but only for the following actions " | ||
"were permission types configured in the " | ||
"permission_type_map: {!r}".format( | ||
self.action, self.permission_type_map | ||
) | ||
) | ||
if perm_type is None: | ||
# Skip permission checking for this action | ||
return | ||
|
||
# Determine whether we've to check object permissions (for detail actions) | ||
obj = None | ||
extra_actions = self.get_extra_actions() | ||
# We have to access the unbound function via __func__ | ||
if handler.__func__ in extra_actions: | ||
if handler.detail: | ||
obj = self.get_object() | ||
elif self.action not in ("create", "list"): | ||
obj = self.get_object() | ||
|
||
# Finally, check permission | ||
perm = self.get_queryset().model.get_perm(perm_type) | ||
if not self.request.user.has_perm(perm, obj): | ||
raise PermissionDenied |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,81 @@ | ||
from __future__ import absolute_import | ||
|
||
import sys | ||
import unittest | ||
|
||
from django.contrib.auth.models import AnonymousUser | ||
from django.core.exceptions import ImproperlyConfigured | ||
from django import http | ||
from django.test import TestCase | ||
from rest_framework.decorators import action | ||
from rest_framework.response import Response | ||
from rest_framework.serializers import ModelSerializer | ||
from rest_framework.test import APIRequestFactory | ||
from rest_framework.viewsets import ModelViewSet | ||
|
||
import rules | ||
from rules.contrib.rest_framework import AutoPermissionViewSetMixin | ||
|
||
|
||
@unittest.skipIf(sys.version_info.major < 3, "Python 3 only") | ||
class AutoPermissionRequiredMixinTests(TestCase): | ||
def setUp(self): | ||
from testapp.models import TestModel | ||
|
||
class TestModelSerializer(ModelSerializer): | ||
class Meta: | ||
model = TestModel | ||
fields = "__all__" | ||
|
||
class TestViewSet(AutoPermissionViewSetMixin, ModelViewSet): | ||
queryset = TestModel.objects.all() | ||
serializer_class = TestModelSerializer | ||
permission_type_map = AutoPermissionViewSetMixin.permission_type_map.copy() | ||
permission_type_map["custom_detail"] = "add" | ||
permission_type_map["custom_nodetail"] = "add" | ||
|
||
@action(detail=True) | ||
def custom_detail(self, request): | ||
return Response() | ||
|
||
@action(detail=False) | ||
def custom_nodetail(self, request): | ||
return Response() | ||
|
||
@action(detail=False) | ||
def unknown(self, request): | ||
return Response() | ||
|
||
self.model = TestModel | ||
self.vs = TestViewSet | ||
self.req = APIRequestFactory().get("/") | ||
self.req.user = AnonymousUser() | ||
|
||
def test_predefined_action(self): | ||
# Create should be allowed due to the add permission set on TestModel | ||
self.assertEqual(self.vs.as_view({"get": "create"})(self.req).status_code, 201) | ||
# List should be allowed due to None in permission_type_map | ||
self.assertEqual( | ||
self.vs.as_view({"get": "list"})(self.req, pk=1).status_code, 200 | ||
) | ||
# Retrieve should be allowed due to the view permission set on TestModel | ||
self.assertEqual( | ||
self.vs.as_view({"get": "retrieve"})(self.req, pk=1).status_code, 200 | ||
) | ||
# Destroy should be forbidden due to missing delete permission | ||
self.assertEqual( | ||
self.vs.as_view({"get": "destroy"})(self.req, pk=1).status_code, 403 | ||
) | ||
|
||
def test_custom_actions(self): | ||
# Both should not produce 403 due to being mapped to the add permission | ||
self.assertEqual( | ||
self.vs.as_view({"get": "custom_detail"})(self.req, pk=1).status_code, 404 | ||
) | ||
self.assertEqual( | ||
self.vs.as_view({"get": "custom_nodetail"})(self.req).status_code, 200 | ||
) | ||
|
||
def test_unknown_action(self): | ||
with self.assertRaises(ImproperlyConfigured): | ||
self.vs.as_view({"get": "unknown"})(self.req) |
Oops, something went wrong.