Skip to content

Commit 1c6e9f6

Browse files
Feature/adding object permission level to multiple permissions required mixin (#316)
* _access: adding object level permission checks for `MultiplePermissionsRequiredMixin` * tests: adding `MultiplePermissionsRequiredWithObjectLevelPermissionsView` * tests: adding backend to test for object level permissions for `MultiplePermissionsRequiredMixin`, and adding tests to check `MultiplePermissionsRequiredMixin` * tests: adding object level permissions for `PermissionRequiredMixin` * adding to `CONTRIBUTORS.TXT` * fixing pycodestyle warning * test_access_mixins: fixing comment
1 parent 84bce81 commit 1c6e9f6

11 files changed

+267
-10
lines changed

CONTRIBUTORS.txt

+1
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ Direct Contributors
3434
* Ben Wilber
3535
* Mfon Eti-mfon
3636
* Irtaza Akram
37+
* Matthew Ethan Tam
3738

3839

3940
Other Contributors

braces/views/_access.py

+14-7
Original file line numberDiff line numberDiff line change
@@ -199,16 +199,14 @@ def check_permissions(self, request):
199199
if self.object_level_permissions:
200200
if hasattr(self, "object") and self.object is not None:
201201
has_permission = request.user.has_perm(
202-
self.get_permission_required(request), self.object
202+
perms, self.object
203203
)
204204
elif hasattr(self, "get_object") and callable(self.get_object):
205205
has_permission = request.user.has_perm(
206-
self.get_permission_required(request), self.get_object()
206+
perms, self.get_object()
207207
)
208208
else:
209-
has_permission = request.user.has_perm(
210-
self.get_permission_required(request)
211-
)
209+
has_permission = request.user.has_perm(perms)
212210
return has_permission
213211

214212
def dispatch(self, request, *args, **kwargs):
@@ -275,21 +273,30 @@ def check_permissions(self, request):
275273
permissions = self.get_permission_required(request)
276274
perms_all = permissions.get("all")
277275
perms_any = permissions.get("any")
276+
instance_object = None
278277

279278
self._check_permissions_keys_set(perms_all, perms_any)
280279
self._check_perms_keys("all", perms_all)
281280
self._check_perms_keys("any", perms_any)
282281

282+
if self.object_level_permissions:
283+
if hasattr(self, "object") and self.object is not None:
284+
instance_object = self.object
285+
elif hasattr(self, "get_object") and callable(self.get_object):
286+
instance_object = self.get_object()
283287
# Check that user has all permissions in the list/tuple
284288
if perms_all:
285289
# Why not `return request.user.has_perms(perms_all)`?
286290
# There may be optional permissions below.
287-
if not request.user.has_perms(perms_all):
291+
if not request.user.has_perms(perms_all, instance_object):
288292
return False
289293

290294
# If perms_any, check that user has at least one in the list/tuple
291295
if perms_any:
292-
any_perms = [request.user.has_perm(perm) for perm in perms_any]
296+
any_perms = [
297+
request.user.has_perm(perm, instance_object)
298+
for perm in perms_any
299+
]
293300
if not any_perms or not any(any_perms):
294301
return False
295302
return True

docs/access.rst

+1
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,7 @@ The ``MultiplePermissionsRequiredMixin`` is a more powerful version of the :ref:
123123
}
124124

125125
The ``MultiplePermissionsRequiredMixin`` also offers a ``check_permissions`` method that should be overridden if you need custom permissions checking.
126+
Additionally similar to ``PermissionRequiredMixin``, ``MultiplePermissionsRequiredMixin`` offers object level permission checking.
126127

127128

128129
.. _GroupRequiredMixin:

tests/backends.py

+28
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
"""Testing backend for object level permissions"""
2+
from tests.helpers import PermissionChecker
3+
4+
5+
class PermissionsCheckerBackend:
6+
"""
7+
Custom Permission Backend for testing Object Level Permissions.
8+
"""
9+
supports_object_permissions = True
10+
supports_anonymous_user = True
11+
supports_inactive_user = True
12+
13+
@staticmethod
14+
def authenticate():
15+
"""Required for a backend"""
16+
return None
17+
18+
@staticmethod
19+
def has_perm(user_obj, perm, obj=None):
20+
"""Used for checking permissions using the `PermissionChecker`"""
21+
check = PermissionChecker(user_obj)
22+
return check.has_perm(perm, obj)
23+
24+
@staticmethod
25+
def has_perms(user_obj, perms: list[str], obj=None):
26+
"""Used for checking multiple permissions using the `PermissionChecker`"""
27+
check = PermissionChecker(user_obj)
28+
return check.has_perms(perms, obj)

tests/factories.py

+34-1
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import factory
22

33
from django.contrib.auth.models import Group, Permission, User
4+
from django.contrib.contenttypes.models import ContentType
45

5-
from .models import Article
6+
from .models import Article, UserObjectPermissions
67

78

89
def _get_perm(perm_name):
@@ -54,3 +55,35 @@ def permissions(self, create, extracted, **kwargs):
5455
if create and extracted:
5556
# We have a saved object and a list of permission names
5657
self.user_permissions.add(*[_get_perm(pn) for pn in extracted])
58+
59+
60+
class ContentTypeFactory(factory.django.DjangoModelFactory):
61+
"""Factory for creating `ContentType` model objects"""
62+
app_label = factory.Sequence(lambda n: f"app_label_{n}")
63+
model = factory.Sequence(lambda n: f"model_{n}")
64+
65+
class Meta:
66+
model = ContentType
67+
abstract = False
68+
69+
70+
class PermissionFactory(factory.django.DjangoModelFactory):
71+
"""Factory for creating `Permission` model objects"""
72+
name = factory.Sequence(lambda n: f"name_{n}")
73+
codename = factory.Sequence(lambda n: f"codename_{n}")
74+
content_type = factory.SubFactory(ContentTypeFactory)
75+
76+
class Meta:
77+
model = Permission
78+
abstract = False
79+
80+
81+
class UserObjectPermissionsFactory(factory.django.DjangoModelFactory):
82+
"""Factory for creating `UserObjectPermissions` model objects"""
83+
user = factory.SubFactory(UserFactory)
84+
permission = factory.SubFactory(PermissionFactory)
85+
article_object = factory.SubFactory(ArticleFactory)
86+
87+
class Meta:
88+
model = UserObjectPermissions
89+
abstract = False

tests/helpers.py

+39-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
from django import test
2-
from django.contrib.auth.models import AnonymousUser
2+
from django.contrib.auth.models import AnonymousUser, User, Permission
33
from django.core.serializers.json import DjangoJSONEncoder
44

5+
from tests.models import UserObjectPermissions
6+
57

68
class TestViewHelper:
79
"""
@@ -65,3 +67,39 @@ def default(self, obj):
6567
if isinstance(obj, set):
6668
return list(obj)
6769
return super(DjangoJSONEncoder, self).default(obj)
70+
71+
72+
class PermissionChecker:
73+
"""
74+
Custom Permission checker for testing of Object Level Permissions
75+
"""
76+
def __init__(self, user: User):
77+
self.user = user
78+
79+
def has_perm(self, perm: str, obj=None) -> bool:
80+
"""This function is used to check for object level permissions"""
81+
if self.user and not self.user.is_active:
82+
return False
83+
elif self.user and self.user.is_superuser:
84+
return True
85+
if "." in perm:
86+
perm = perm.split(".", maxsplit=1)[1]
87+
permission_obj = Permission.objects.get(codename=perm)
88+
if obj is None:
89+
return perm in self.user.get_all_permissions(perm)
90+
return UserObjectPermissions.objects.filter(
91+
permission=permission_obj,
92+
user=self.user,
93+
article_object=obj
94+
).exists()
95+
96+
def has_perms(self, perms: list[str], obj=None) -> bool:
97+
"""This function is used to check for object level permissions"""
98+
if self.user and not self.user.is_active:
99+
return False
100+
elif self.user and self.user.is_superuser:
101+
return True
102+
if not perms:
103+
return False
104+
return all(self.has_perm(perm) for perm in perms)
105+

tests/models.py

+9
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from django.contrib.auth.models import Permission, User
12
from django.db import models
23

34

@@ -29,3 +30,11 @@ def get_canonical_slug(self):
2930
if self.author:
3031
return f"{self.author.username}-{self.slug}"
3132
return f"unauthored-{self.slug}"
33+
34+
35+
class UserObjectPermissions(models.Model):
36+
"""Django model used to test and assign object level permissions"""
37+
user = models.ForeignKey(User, on_delete=models.CASCADE)
38+
permission = models.ForeignKey(Permission, on_delete=models.CASCADE)
39+
article_object = models.ForeignKey(Article, on_delete=models.CASCADE)
40+

tests/settings.py

+5
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,11 @@
1616
"default": {"ENGINE": "django.db.backends.sqlite3", "NAME": ":memory:"}
1717
}
1818

19+
AUTHENTICATION_BACKENDS = (
20+
'django.contrib.auth.backends.ModelBackend',
21+
'tests.backends.PermissionsCheckerBackend',
22+
)
23+
1924
MIDDLEWARE_CLASSES = [
2025
"django.middleware.common.CommonMiddleware",
2126
"django.contrib.sessions.middleware.SessionMiddleware",

tests/test_access_mixins.py

+109-1
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import pytest
44

55
from django import test
6+
from django.contrib.auth.models import Permission
67
from django.test.utils import override_settings
78
from django.core.exceptions import ImproperlyConfigured, PermissionDenied
89
from django.http import Http404, HttpResponse
@@ -11,7 +12,7 @@
1112

1213
from django.urls import reverse_lazy
1314

14-
from .factories import GroupFactory, UserFactory
15+
from .factories import GroupFactory, UserFactory, UserObjectPermissionsFactory, ArticleFactory
1516
from .helpers import TestViewHelper
1617
from .views import (
1718
PermissionRequiredView,
@@ -413,6 +414,50 @@ def test_invalid_permission(self):
413414
with self.assertRaises(ImproperlyConfigured):
414415
self.dispatch_view(self.build_request(), permission_required=None)
415416

417+
def test_object_level_permissions(self):
418+
"""
419+
Tests that object level permissions perform as expected, where object level permissions and
420+
global level permissions
421+
"""
422+
# Arrange
423+
article = ArticleFactory()
424+
self.view_class = PermissionRequiredView
425+
self.view_url = f"/object_level_permission_required/?pk={article.pk}"
426+
tests_add_article = Permission.objects.get(codename="add_article")
427+
permissions = "tests.add_article"
428+
valid_user = UserFactory(permissions=[permissions])
429+
invalid_user_1 = UserFactory(permissions=["auth.add_user"])
430+
invalid_user_2 = UserFactory(permissions=[permissions])
431+
UserObjectPermissionsFactory(
432+
user=valid_user, permission=tests_add_article, article_object=article
433+
)
434+
# Act
435+
valid_req = self.build_request(path=self.view_url, user=valid_user)
436+
valid_resp = self.dispatch_view(
437+
valid_req,
438+
permission_required=permissions,
439+
object_level_permissions=True,
440+
raise_exception=True
441+
)
442+
invalid_req_1 = self.build_request(path=self.view_url, user=invalid_user_1)
443+
invalid_req_2 = self.build_request(path=self.view_url, user=invalid_user_2)
444+
# Assert
445+
self.assertEqual(valid_resp.status_code, 200)
446+
with self.assertRaises(PermissionDenied):
447+
self.dispatch_view(
448+
invalid_req_1,
449+
permission_required=permissions,
450+
object_level_permissions=True,
451+
raise_exception=True
452+
)
453+
with self.assertRaises(PermissionDenied):
454+
self.dispatch_view(
455+
invalid_req_2,
456+
permission_required=permissions,
457+
object_level_permissions=True,
458+
raise_exception=True
459+
)
460+
416461

417462
@pytest.mark.django_db
418463
class TestMultiplePermissionsRequiredMixin(
@@ -534,6 +579,69 @@ def test_any_permissions_key(self):
534579
permissions=permissions,
535580
)
536581

582+
def test_all_object_level_permissions_key(self):
583+
"""
584+
Tests that when a user has all the correct object level permissions, response is OK,
585+
else forbidden.
586+
"""
587+
# Arrange
588+
article = ArticleFactory()
589+
self.view_class = MultiplePermissionsRequiredView
590+
self.view_url = f"/multiple_object_level_permissions_required/?pk={article.pk}"
591+
auth_add_user = Permission.objects.get(codename="add_user")
592+
tests_add_article = Permission.objects.get(codename="add_article")
593+
permissions = {"all": ["auth.add_user", "tests.add_article"]}
594+
valid_user = UserFactory(permissions=permissions["all"])
595+
invalid_user = UserFactory(permissions=["auth.add_user"])
596+
UserObjectPermissionsFactory(user=valid_user, permission=auth_add_user, article_object=article)
597+
UserObjectPermissionsFactory(user=valid_user, permission=tests_add_article, article_object=article)
598+
# Act
599+
valid_req = self.build_request(path=self.view_url, user=valid_user)
600+
valid_resp = self.dispatch_view(
601+
valid_req, permissions=permissions, object_level_permissions=True
602+
)
603+
invalid_req = self.build_request(path=self.view_url, user=invalid_user)
604+
# Arrange
605+
self.assertEqual(valid_resp.status_code, 200)
606+
with self.assertRaises(PermissionDenied):
607+
self.dispatch_view(
608+
invalid_req, permissions=permissions, object_level_permissions=True, raise_exception=True
609+
)
610+
611+
def test_any_object_level_permissions_key(self):
612+
"""
613+
Tests that when a user has any the correct object level permissions, response is OK,
614+
else forbidden.
615+
"""
616+
# Arrange
617+
article = ArticleFactory()
618+
self.view_url = f"/multiple_object_level_permissions_required/?pk={article.pk}"
619+
self.view_class = MultiplePermissionsRequiredView
620+
auth_add_user = Permission.objects.get(codename="add_user")
621+
tests_add_article = Permission.objects.get(codename="add_article")
622+
permissions = {"any": ["auth.add_user", "tests.add_article"]}
623+
user = UserFactory(permissions=[permissions["any"][0]])
624+
user_1 = UserFactory()
625+
user_2 = UserFactory(permissions=permissions["any"])
626+
UserObjectPermissionsFactory(user=user, permission=auth_add_user, article_object=article)
627+
UserObjectPermissionsFactory(user=user, permission=tests_add_article, article_object=article)
628+
# Act
629+
valid_req = self.build_request(path=self.view_url, user=user)
630+
valid_resp = self.dispatch_view(
631+
valid_req, permissions=permissions, object_level_permissions=True, raise_exception=True
632+
)
633+
invalid_req_1 = self.build_request(path=self.view_url, user=user_1)
634+
invalid_req_2 = self.build_request(path=self.view_url, user=user_2)
635+
# Assert
636+
self.assertEqual(valid_resp.status_code, 200)
637+
with self.assertRaises(PermissionDenied):
638+
self.dispatch_view(
639+
invalid_req_1, permissions=permissions, object_level_permissions=True, raise_exception=True
640+
)
641+
with self.assertRaises(PermissionDenied):
642+
self.dispatch_view(invalid_req_2, permissions=permissions, object_level_permissions=True, raise_exception=True)
643+
644+
537645

538646
@pytest.mark.django_db
539647
class TestSuperuserRequiredMixin(_TestAccessBasicsMixin, test.TestCase):

tests/urls.py

+5
Original file line numberDiff line numberDiff line change
@@ -70,11 +70,16 @@
7070
path("context/", views.ContextView.as_view(), name="context"),
7171
# PermissionRequiredMixin tests
7272
path("permission_required/", views.PermissionRequiredView.as_view()),
73+
path("object_level_permission_required/", views.PermissionRequiredView.as_view(object_level_permissions=True)),
7374
# MultiplePermissionsRequiredMixin tests
7475
path(
7576
"multiple_permissions_required/",
7677
views.MultiplePermissionsRequiredView.as_view(),
7778
),
79+
path(
80+
"multiple_object_level_permissions_required/",
81+
views.MultiplePermissionsRequiredView.as_view(object_level_permissions=True),
82+
),
7883
# SuperuserRequiredMixin tests
7984
path("superuser_required/", views.SuperuserRequiredView.as_view()),
8085
# StaffuserRequiredMixin tests

0 commit comments

Comments
 (0)