Skip to content

Commit e1da97e

Browse files
authored
Merge pull request #100 from efficiosoft/auto-view-mixin
CBV mixin for checking permissions automatically based on view type
2 parents 3605599 + e35c5a5 commit e1da97e

File tree

6 files changed

+425
-51
lines changed

6 files changed

+425
-51
lines changed

README.rst

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ Table of Contents
6161
- `Permissions in views`_
6262
- `Permissions and rules in templates`_
6363
- `Permissions in the Admin`_
64+
- `Permissions in Django Rest Framework`_
6465

6566
- `Advanced features`_
6667

@@ -536,6 +537,32 @@ For more information refer to the `Django documentation`_ and the
536537

537538
.. _Django documentation: https://docs.djangoproject.com/en/1.9/topics/auth/default/#limiting-access-to-logged-in-users
538539

540+
Checking permission automatically based on view type
541+
++++++++++++++++++++++++++++++++++++++++++++++++++++
542+
543+
If you use the mechanisms provided by ``rules.contrib.models`` to register permissions
544+
for your models as described in `Permissions in models`_, there's another convenient
545+
mixin for class-based views available for you.
546+
547+
``rules.contrib.views.AutoPermissionRequiredMixin`` can recognize the type of view
548+
it's used with and check for the corresponding permission automatically.
549+
550+
This example view would, without any further configuration, automatically check for
551+
the ``"posts.change_post"`` permission, given that the app label is ``"posts"``::
552+
553+
from django.views.generic import UpdateView
554+
from rules.contrib.views import AutoPermissionRequiredMixin
555+
from posts.models import Post
556+
557+
class UpdatePostView(AutoPermissionRequiredMixin, UpdateView):
558+
model = Post
559+
560+
By default, the generic CRUD views from ``django.views.generic`` are mapped to the
561+
native Django permission types (*add*, *change*, *delete* and *view*). However,
562+
the pre-defined mappings can be extended, changed or replaced altogether when
563+
subclassing ``AutoPermissionRequiredMixin``. See the fully documented source code
564+
for details on how to do that properly.
565+
539566

540567
Permissions and rules in templates
541568
----------------------------------
@@ -638,6 +665,42 @@ different: ``rules`` will ask for the change permission if and only if no rule
638665
exists for the view permission.
639666

640667

668+
Permissions in Django Rest Framework
669+
------------------------------------
670+
671+
Similar to ``rules.contrib.views.AutoPermissionRequiredMixin``, there is a
672+
``rules.contrib.rest_framework.AutoPermissionViewSetMixin`` for viewsets in Django
673+
Rest Framework. The difference is that it doesn't derive permission from the type
674+
of view but from the API action (*create*, *retrieve* etc.) that's tried to be
675+
performed. Of course, it requires you to use the mixins from ``rules.contrib.models``
676+
when declaring models the API should operate on.
677+
678+
Here is a possible ``ModelViewSet`` for the ``Post`` model with fully automated CRUD
679+
permission checking::
680+
681+
from rest_framework.serializers import ModelSerializer
682+
from rest_framework.viewsets import ModelViewSet
683+
from rules.contrib.rest_framework import AutoPermissionViewSetMixin
684+
from posts.models import Post
685+
686+
class PostSerializer(ModelSerializer):
687+
class Meta:
688+
model = Post
689+
fields = "__all__"
690+
691+
class PostViewSet(AutoPermissionViewSetMixin, ModelViewSet):
692+
queryset = Post.objects.all()
693+
serializer_class = PostSerializer
694+
695+
By default, the CRUD actions of ``ModelViewSet`` are mapped to the native
696+
Django permission types (*add*, *change*, *delete* and *view*). The ``list``
697+
action has no permission checking enabled. However, the pre-defined mappings
698+
can be extended, changed or replaced altogether when using (or subclassing)
699+
``AutoPermissionViewSetMixin``. Custom API actions defined via the ``@action``
700+
decorator may then be mapped as well. See the fully documented source code for
701+
details on how to properly customize the default behaviour.
702+
703+
641704
Advanced features
642705
=================
643706

rules/contrib/rest_framework.py

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
from django.core.exceptions import ImproperlyConfigured, PermissionDenied
2+
3+
4+
class AutoPermissionViewSetMixin:
5+
"""
6+
Enforces object-level permissions in ``rest_framework.viewsets.ViewSet``,
7+
deriving the permission type from the particular action to be performed..
8+
9+
As with ``rules.contrib.views.AutoPermissionRequiredMixin``, this only works when
10+
model permissions are registered using ``rules.contrib.models.RulesModelMixin``.
11+
"""
12+
13+
# Maps API actions to model permission types. None as value skips permission
14+
# checks for the particular action.
15+
# This map needs to be extended when custom actions are implemented
16+
# using the @action decorator.
17+
# Extend or replace it in subclasses like so:
18+
# permission_type_map = {
19+
# **AutoPermissionViewSetMixin.permission_type_map,
20+
# "close": "change",
21+
# "reopen": "change",
22+
# }
23+
permission_type_map = {
24+
"create": "add",
25+
"destroy": "delete",
26+
"list": None,
27+
"partial_update": "change",
28+
"retrieve": "view",
29+
"update": "change",
30+
}
31+
32+
def initial(self, *args, **kwargs):
33+
"""Ensures user has permission to perform the requested action."""
34+
super().initial(*args, **kwargs)
35+
36+
if not self.request.user:
37+
# No user, don't check permission
38+
return
39+
40+
# Get the handler for the HTTP method in use
41+
try:
42+
if self.request.method.lower() not in self.http_method_names:
43+
raise AttributeError
44+
handler = getattr(self, self.request.method.lower())
45+
except AttributeError:
46+
# method not supported, will be denied anyway
47+
return
48+
49+
try:
50+
perm_type = self.permission_type_map[self.action]
51+
except KeyError:
52+
raise ImproperlyConfigured(
53+
"AutoPermissionViewSetMixin tried to authorize a request with the "
54+
"{!r} action, but permission_type_map only contains: {!r}".format(
55+
self.action, self.permission_type_map
56+
)
57+
)
58+
if perm_type is None:
59+
# Skip permission checking for this action
60+
return
61+
62+
# Determine whether we've to check object permissions (for detail actions)
63+
obj = None
64+
extra_actions = self.get_extra_actions()
65+
# We have to access the unbound function via __func__
66+
if handler.__func__ in extra_actions:
67+
if handler.detail:
68+
obj = self.get_object()
69+
elif self.action not in ("create", "list"):
70+
obj = self.get_object()
71+
72+
# Finally, check permission
73+
perm = self.get_queryset().model.get_perm(perm_type)
74+
if not self.request.user.has_perm(perm, obj):
75+
raise PermissionDenied

rules/contrib/views.py

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from django.shortcuts import get_object_or_404
99
from django.utils.decorators import available_attrs
1010
from django.utils.encoding import force_text
11+
from django.views.generic import CreateView, DeleteView, DetailView, UpdateView
1112

1213

1314
# These are made available for convenience, as well as for use in Django
@@ -50,6 +51,85 @@ def has_permission(self):
5051
return self.request.user.has_perms(perms, obj)
5152

5253

54+
class AutoPermissionRequiredMixin(PermissionRequiredMixin):
55+
"""
56+
An extended variant of PermissionRequiredMixin which automatically determines
57+
the permission to check based on the type of view it's used with.
58+
59+
It works by checking the current view for being an instance of a pre-defined
60+
list of view types. On a match, the corresponding permission type (such as
61+
"add" or "change") is converted into the full model-specific permission name
62+
and checked. See the permission_type_map attribute for the default view type ->
63+
permission type mappings.
64+
65+
When a view using this mixin has an attribute ``permission_type``, that type
66+
is used directly and overwrites the permission_type_map for the particular
67+
view. A permission type of ``None`` (either as ``permission_type`` or in
68+
``permission_type_map``) causes permission checking to be skipped. If the type
69+
of permission to check for should depend on dynamic factors other than the view
70+
type, you may overwrite the ``permission_type`` attribute with a ``@property``.
71+
72+
The ``permission_required`` attribute behaves like it does in
73+
``PermissionRequiredMixin`` and can be used to specify concrete permission name(s)
74+
to be checked in addition to the automatically derived one.
75+
76+
NOTE: The model-based permission registration from ``rules.contrib.models``
77+
must be used with the models for which you create views using this mixin,
78+
because the permission names are derived via ``RulesModelMixin.get_perm()``
79+
internally. The second requirement is the presence of either an attribute
80+
``model`` holding the ``Model`` the view acts on, or the ``get_queryset()``
81+
method as provided by Django's ``SingleObjectMixin``. Hence with the normal
82+
model views, you don't need to care about anything.
83+
"""
84+
85+
# These reflect Django's default model permissions. If needed, this list can be
86+
# extended or replaced entirely when subclassing, like so:
87+
# permission_type_map = [
88+
# (SomeCustomViewType, "add"),
89+
# (SomeOtherCustomViewType, "some_fancy_action"),
90+
# *AutoPermissionRequiredMixin.permission_type_map,
91+
# ]
92+
# Note that ordering matters, which is why this is a list and not a dict. The
93+
# first entry for which isinstance(self, view_type) returns True will be used.
94+
permission_type_map = [
95+
(CreateView, "add"),
96+
(UpdateView, "change"),
97+
(DeleteView, "delete"),
98+
(DetailView, "view"),
99+
]
100+
101+
def get_permission_required(self):
102+
"""Adds the correct permission to check according to view type."""
103+
try:
104+
perm_type = self.permission_type
105+
except AttributeError:
106+
# Perform auto-detection by view type
107+
for view_type, _perm_type in self.permission_type_map:
108+
if isinstance(self, view_type):
109+
perm_type = _perm_type
110+
break
111+
else:
112+
raise ImproperlyConfigured(
113+
"AutoPermissionRequiredMixin was used, but permission_type was "
114+
"neither set nor could be determined automatically for {0}. "
115+
"Consider setting permission_type on the view manually or "
116+
"adding {0} to the permission_type_map."
117+
.format(self.__class__.__name__)
118+
)
119+
120+
perms = []
121+
if perm_type is not None:
122+
model = getattr(self, "model", None)
123+
if model is None:
124+
model = self.get_queryset().model
125+
perms.append(model.get_perm(perm_type))
126+
127+
# If additional permissions have been defined, consider them as well
128+
if self.permission_required is not None:
129+
perms.extend(super().get_permission_required())
130+
return perms
131+
132+
53133
def objectgetter(model, attr_name='pk', field_name='pk'):
54134
"""
55135
Helper that returns a function suitable for use as the ``fn`` argument
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
from __future__ import absolute_import
2+
3+
import sys
4+
import unittest
5+
6+
from django.contrib.auth.models import AnonymousUser
7+
from django.core.exceptions import ImproperlyConfigured
8+
from django import http
9+
from django.test import TestCase
10+
from rest_framework.decorators import action
11+
from rest_framework.response import Response
12+
from rest_framework.serializers import ModelSerializer
13+
from rest_framework.test import APIRequestFactory
14+
from rest_framework.viewsets import ModelViewSet
15+
16+
import rules
17+
from rules.contrib.rest_framework import AutoPermissionViewSetMixin
18+
19+
20+
@unittest.skipIf(sys.version_info.major < 3, "Python 3 only")
21+
class AutoPermissionRequiredMixinTests(TestCase):
22+
def setUp(self):
23+
from testapp.models import TestModel
24+
25+
class TestModelSerializer(ModelSerializer):
26+
class Meta:
27+
model = TestModel
28+
fields = "__all__"
29+
30+
class TestViewSet(AutoPermissionViewSetMixin, ModelViewSet):
31+
queryset = TestModel.objects.all()
32+
serializer_class = TestModelSerializer
33+
permission_type_map = AutoPermissionViewSetMixin.permission_type_map.copy()
34+
permission_type_map["custom_detail"] = "add"
35+
permission_type_map["custom_nodetail"] = "add"
36+
37+
@action(detail=True)
38+
def custom_detail(self, request):
39+
return Response()
40+
41+
@action(detail=False)
42+
def custom_nodetail(self, request):
43+
return Response()
44+
45+
@action(detail=False)
46+
def unknown(self, request):
47+
return Response()
48+
49+
self.model = TestModel
50+
self.vs = TestViewSet
51+
self.req = APIRequestFactory().get("/")
52+
self.req.user = AnonymousUser()
53+
54+
def test_predefined_action(self):
55+
# Create should be allowed due to the add permission set on TestModel
56+
self.assertEqual(self.vs.as_view({"get": "create"})(self.req).status_code, 201)
57+
# List should be allowed due to None in permission_type_map
58+
self.assertEqual(
59+
self.vs.as_view({"get": "list"})(self.req, pk=1).status_code, 200
60+
)
61+
# Retrieve should be allowed due to the view permission set on TestModel
62+
self.assertEqual(
63+
self.vs.as_view({"get": "retrieve"})(self.req, pk=1).status_code, 200
64+
)
65+
# Destroy should be forbidden due to missing delete permission
66+
self.assertEqual(
67+
self.vs.as_view({"get": "destroy"})(self.req, pk=1).status_code, 403
68+
)
69+
70+
def test_custom_actions(self):
71+
# Both should not produce 403 due to being mapped to the add permission
72+
self.assertEqual(
73+
self.vs.as_view({"get": "custom_detail"})(self.req, pk=1).status_code, 404
74+
)
75+
self.assertEqual(
76+
self.vs.as_view({"get": "custom_nodetail"})(self.req).status_code, 200
77+
)
78+
79+
def test_unknown_action(self):
80+
with self.assertRaises(ImproperlyConfigured):
81+
self.vs.as_view({"get": "unknown"})(self.req)

0 commit comments

Comments
 (0)