Skip to content

Commit

Permalink
Add mixins for CBV and DRF that check permissions automatically
Browse files Browse the repository at this point in the history
  • Loading branch information
Robert Schindler committed Aug 7, 2019
1 parent bb09e04 commit ca9a984
Show file tree
Hide file tree
Showing 6 changed files with 426 additions and 51 deletions.
63 changes: 63 additions & 0 deletions README.rst
Expand Up @@ -61,6 +61,7 @@ Table of Contents
- `Permissions in views`_
- `Permissions and rules in templates`_
- `Permissions in the Admin`_
- `Permissions in Django Rest Framework`_

- `Advanced features`_

Expand Down Expand Up @@ -528,6 +529,32 @@ For more information refer to the `Django documentation`_ and the

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

Checking permission automatically based on view type
++++++++++++++++++++++++++++++++++++++++++++++++++++

If you use the mechanisms provided by ``rules.contrib.models`` to register permissions
for your models as described in `Permissions in models`_, there's another convenient
to use mixin for class-based views available for you.

``rules.contrib.views.AutoPermissionRequiredMixin`` can recognize the type of view
it's used with and check for the corresponding permission automatically.

This example view would, without any further configuration, automatically check for
the ``"posts.change_post"`` permission, given that the app label is ``"posts"``::

from django.views.generic import UpdateView
from rules.contrib.views import AutoPermissionRequiredMixin
from posts.models import Post

class UpdatePostView(AutoPermissionRequiredMixin, UpdateView):
model = Post

By default, the generic CRUD views from ``django.views.generic`` are mapped to the
native Django permission types (*add*, *change*, *delete* and *view*). However,
the pre-defined mappings can be extended, changed or replaced altogether when
subclassing ``AutoPermissionRequiredMixin``. See the fully documented source code
for details on how to do that properly.


Permissions and rules in templates
----------------------------------
Expand Down Expand Up @@ -630,6 +657,42 @@ different: ``rules`` will ask for the change permission if and only if no rule
exists for the view permission.


Permissions in Django Rest Framework
------------------------------------

Similar to ``rules.contrib.views.AutoPermissionRequiredMixin``, there is a
``rules.contrib.rest_framework.AutoPermissionViewSetMixin`` for viewsets in Django
Rest Framework. The difference is that it doesn't derive permission from the type
of view but from the API action (*create*, *retrieve* etc.) that's tried to be
performed. Of course, it requires you to use the mixins from ``rules.contrib.models``
when declaring models the API should operate on.

Here is a possible ``ModelViewSet`` for the ``Post`` model with fully automated CRUD
permission checking::

from rest_framework.serializers import ModelSerializer
from rest_framework.viewsets import ModelViewSet
from rules.contrib.rest_framework import AutoPermissionViewSetMixin
from posts.models import Post

class PostSerializer(ModelSerializer):
class Meta:
model = Post
fields = "__all__"

class PostViewSet(AutoPermissionViewSetMixin, ModelViewSet):
queryset = Post.objects.all()
serializer_class = PostSerializer

By default, the CRUD actions of ``ModelViewSet`` are mapped to the native
Django permission types (*add*, *change*, *delete* and *view*). The ``list``
action has no permission checking enabled. However, the pre-defined mappings
can be extended, changed or replaced altogether when using (or subclassing)
``AutoPermissionViewSetMixin``. Custom API actions defined via the ``@action``
decorator may then be mapped as well. See the fully documented source code for
details on how to properly customize the default behaviour.


Advanced features
=================

Expand Down
77 changes: 77 additions & 0 deletions rules/contrib/rest_framework.py
@@ -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
79 changes: 79 additions & 0 deletions rules/contrib/views.py
Expand Up @@ -9,6 +9,7 @@
from django.utils import six
from django.utils.decorators import available_attrs
from django.utils.encoding import force_text
from django.views.generic import CreateView, DeleteView, DetailView, UpdateView


# These are made available for convenience, as well as for use in Django
Expand Down Expand Up @@ -49,6 +50,84 @@ def has_permission(self):
return self.request.user.has_perms(perms, obj)


class AutoPermissionRequiredMixin(PermissionRequiredMixin):
"""
An extended variant of PermissionRequiredMixin which automatically determines
the permission to check based on the type of view it's used with.
It works by checking the current view for being an instance of a pre-defined
list of view types. On a match, the corresponding permission type (such as
"add" or "change") is converted into the full model-specific permission name
and checked. See the permission_type_map attribute for the default view type ->
permission type mappings.
When a view using this mixin has an attribute ``permission_type``, that type is
used directly and overwrites the permission_type_map for the particular view. A
permission type of ``None`` causes permission checking to be skipped. If the
type of permission to check for should depend on dynamic factors other than the
view type, you may overload the ``permission_type`` attribute with a ``property``.
The ``permission_required`` attribute behaves like it does in
``PermissionRequiredMixin`` and can be used to specify concrete permission name(s)
to be checked in addition to the automatically derived one.
NOTE: The model-based permission registration from ``rules.contrib.models``
must be used with the models for which you create views using this mixin,
because the permission names are derived via ``RulesModelMixin.get_perm()``
internally. The second requirement is the presence of either an attribute
``model`` holding the ``Model`` the view acts on, or the ``get_queryset()``
method as provided by Django's ``SingleObjectMixin``. Hence with the normal
model views, you don't need to care about anything.
"""

# These reflect Django's default model permissions. If needed, this list can be
# extended or replaced entirely when subclassing, like so:
# permission_type_map = [
# (SomeCustomViewType, "add"),
# (SomeOtherCustomViewType, "some_fancy_action"),
# *AutoPermissionRequiredMixin.permission_type_map,
# ]
# Note that ordering matters, which is why this is a list and not a dict. The
# first entry for which isinstance(self, view_type) returns True will be used.
permission_type_map = [
(CreateView, "add"),
(UpdateView, "change"),
(DeleteView, "delete"),
(DetailView, "view"),
]

def get_permission_required(self):
"""Adds the correct permission to check according to view type."""
try:
perm_type = self.permission_type
except AttributeError:
# Perform auto-detection by view type
for view_type, _perm_type in self.permission_type_map:
if isinstance(self, view_type):
perm_type = _perm_type
break
else:
raise ImproperlyConfigured(
"AutoPermissionRequiredMixin was used, but permission_type was "
"neither set nor could be determined automatically for {0}. "
"Consider setting permission_type on the view manually or "
"adding {0} to the permission_type_map."
.format(self.__class__.__name__)
)

perms = []
if perm_type is not None:
model = getattr(self, "model", None)
if model is None:
model = self.get_queryset().model
perms.append(model.get_perm(perm_type))

# If additional permissions have been defined, consider them as well
if self.permission_required is not None:
perms.extend(super().get_permission_required())
return perms


def objectgetter(model, attr_name='pk', field_name='pk'):
"""
Helper that returns a function suitable for use as the ``fn`` argument
Expand Down
81 changes: 81 additions & 0 deletions tests/testsuite/contrib/test_rest_framework.py
@@ -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)

0 comments on commit ca9a984

Please sign in to comment.