Skip to content

Commit 70e0ffa

Browse files
[feature] Added permission class with support for view permissions #249
Closes #249
1 parent 0d791a8 commit 70e0ffa

6 files changed

Lines changed: 198 additions & 3 deletions

File tree

README.rst

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -600,6 +600,27 @@ Ensure the queryset of your views make use of
600600
`select_related <https://docs.djangoproject.com/en/3.0/ref/models/querysets/#select-related>`_
601601
in these cases to avoid generating too many queries.
602602

603+
``DjangoModelPermissions``
604+
~~~~~~~~~~~~~~~~~~~~~~~~~~
605+
606+
The default ``DjangoModelPermissions`` class doesn't checks for the
607+
``view`` permission of any object for ``GET`` requests. The extended
608+
``DjangoModelPermissions`` class overcomes this problem. In order to
609+
allow ``GET`` requests on any object it checks for the availability
610+
of either ``view`` or ``change`` permissions.
611+
612+
Usage example:
613+
614+
.. code-block:: python
615+
616+
from openwisp_users.api.permissions import DjangoModelPermissions
617+
from rest_framework.generics import ListCreateAPIView
618+
619+
class TemplateListCreateView(ListCreateAPIView):
620+
serializer_class = TemplateSerializer
621+
permission_classes = (DjangoModelPermissions,)
622+
queryset = Template.objects.all()
623+
603624
Django REST Framework Mixins
604625
----------------------------
605626

openwisp_users/api/permissions.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
from django.utils.translation import gettext_lazy as _
22
from rest_framework.permissions import BasePermission
3+
from rest_framework.permissions import (
4+
DjangoModelPermissions as BaseDjangoModelPermissions,
5+
)
36
from swapper import load_model
47

58
Organization = load_model('openwisp_users', 'Organization')
@@ -66,3 +69,34 @@ class IsOrganizationOwner(BaseOrganizationPermission):
6669

6770
def validate_membership(self, user, org):
6871
return org and (user.is_superuser or user.is_owner(org))
72+
73+
74+
class DjangoModelPermissions(BaseDjangoModelPermissions):
75+
perms_map = {
76+
'GET': ['%(app_label)s.view_%(model_name)s'],
77+
'OPTIONS': [],
78+
'HEAD': ['%(app_label)s.view_%(model_name)s'],
79+
'POST': ['%(app_label)s.add_%(model_name)s'],
80+
'PUT': ['%(app_label)s.change_%(model_name)s'],
81+
'PATCH': ['%(app_label)s.change_%(model_name)s'],
82+
'DELETE': ['%(app_label)s.delete_%(model_name)s'],
83+
}
84+
85+
def has_permission(self, request, view):
86+
# Workaround to ensure DjangoModelPermissions are not applied
87+
# to the root view when using DefaultRouter.
88+
if getattr(view, '_ignore_model_permissions', False):
89+
return True
90+
91+
user = request.user
92+
if not user or (not user.is_authenticated and self.authenticated_users_only):
93+
return False
94+
95+
queryset = self._queryset(view)
96+
perms = self.get_required_permissions(request.method, queryset.model)
97+
change_perm = self.get_required_permissions('PUT', queryset.model)
98+
99+
if request.method == 'GET':
100+
return user.has_perms(perms) or user.has_perms(change_perm)
101+
102+
return user.has_perms(perms)

tests/testapp/__init__.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,11 @@ def _create_shelf(self, **kwargs):
1414
s.full_clean()
1515
s.save()
1616
return s
17+
18+
def _create_template(self, **kwargs):
19+
options = dict(name='test-template')
20+
options.update(kwargs)
21+
t = self.template_model(**options)
22+
t.full_clean()
23+
t.save()
24+
return t

tests/testapp/tests/test_permission_classes.py

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,23 @@
1+
from django.contrib.auth import get_user_model
2+
from django.contrib.auth.models import Permission
13
from django.test import TestCase
24
from django.urls import reverse
5+
from swapper import load_model
36

47
from openwisp_users.api.throttling import AuthRateThrottle
58

9+
from ..models import Template
610
from .mixins import TestMultitenancyMixin
711

12+
User = get_user_model()
13+
Group = load_model('openwisp_users', 'Group')
14+
OrganizationUser = load_model('openwisp_users', 'OrganizationUser')
15+
816

917
class TestPermissionClasses(TestMultitenancyMixin, TestCase):
1018
def setUp(self):
1119
AuthRateThrottle.rate = 0
20+
self.template_model = Template
1221
self.member_url = reverse('test_api_member_view')
1322
self.manager_url = reverse('test_api_manager_view')
1423
self.owner_url = reverse('test_api_owner_view')
@@ -117,3 +126,106 @@ def test_organization_field_with_errored_parent(self):
117126
with self.assertRaises(AttributeError) as error:
118127
self.client.get(reverse('test_error_field_view'), **auth)
119128
self.assertIn('Organization not found', str(error.exception))
129+
130+
def _get_auth_template(self, user, org1):
131+
OrganizationUser.objects.create(user=user, organization=org1, is_admin=True)
132+
self.client.force_login(user)
133+
token = self._obtain_auth_token(user)
134+
auth = dict(HTTP_AUTHORIZATION=f'Bearer {token}')
135+
t1 = self._create_template(organization=org1)
136+
return (auth, t1)
137+
138+
def test_view_permission_with_operator(self):
139+
user = self._get_user()
140+
operator_group = Group.objects.filter(name='Operator')
141+
user.groups.set(operator_group)
142+
org1 = self._get_org()
143+
auth, t1 = self._get_auth_template(user, org1)
144+
with self.subTest('Get Template List'):
145+
response = self.client.get(reverse('test_template_list'), **auth)
146+
self.assertEqual(response.status_code, 403)
147+
with self.subTest('Get Template Detail'):
148+
response = self.client.get(
149+
reverse('test_template_detail', args=[t1.pk]), **auth
150+
)
151+
self.assertEqual(response.status_code, 403)
152+
153+
def test_view_permission_with_administrator(self):
154+
user = self._get_user()
155+
administrator_group = Group.objects.get(name='Administrator')
156+
change_perm = Permission.objects.get(codename='change_template')
157+
administrator_group.permissions.add(change_perm)
158+
user.groups.add(administrator_group)
159+
org1 = self._get_org()
160+
auth, t1 = self._get_auth_template(user, org1)
161+
with self.subTest('Get Template List'):
162+
response = self.client.get(reverse('test_template_list'), **auth)
163+
self.assertEqual(response.status_code, 200)
164+
with self.subTest('Get Template Detail'):
165+
response = self.client.get(
166+
reverse('test_template_detail', args=[t1.pk]), **auth
167+
)
168+
self.assertEqual(response.status_code, 200)
169+
permissions = administrator_group.permissions.values_list('codename', flat=True)
170+
self.assertFalse('view_template' in permissions)
171+
self.assertTrue('change_template' in permissions)
172+
173+
def test_view_permission_with_operator_having_view_perm(self):
174+
user = self._get_user()
175+
operator_group = Group.objects.get(name='Operator')
176+
view_perm = Permission.objects.get(codename='view_template')
177+
operator_group.permissions.add(view_perm)
178+
user.groups.add(operator_group)
179+
org1 = self._get_org()
180+
auth, t1 = self._get_auth_template(user, org1)
181+
with self.subTest('Get Template List'):
182+
response = self.client.get(reverse('test_template_list'), **auth)
183+
self.assertEqual(response.status_code, 200)
184+
with self.subTest('Get Template Detail'):
185+
response = self.client.get(
186+
reverse('test_template_detail', args=[t1.pk]), **auth
187+
)
188+
self.assertEqual(response.status_code, 200)
189+
with self.subTest('Change Template Detail'):
190+
data = {'name': 'change-template'}
191+
response = self.client.patch(
192+
reverse('test_template_detail', args=[t1.pk]), data, **auth
193+
)
194+
self.assertEqual(response.status_code, 403)
195+
with self.subTest('Delete Template'):
196+
response = self.client.delete(
197+
reverse('test_template_detail', args=[t1.pk]), **auth
198+
)
199+
self.assertEqual(response.status_code, 403)
200+
201+
def test_view_django_model_permission_with_view_perm(self):
202+
user = self._get_user()
203+
user_permissions = Permission.objects.filter(codename='view_template')
204+
user.user_permissions.add(*user_permissions)
205+
user.organizations_dict # force caching
206+
org1 = self._get_org()
207+
auth, t1 = self._get_auth_template(user, org1)
208+
with self.subTest('Get Template List'):
209+
response = self.client.get(reverse('test_template_list'), **auth)
210+
self.assertEqual(response.status_code, 200)
211+
with self.subTest('Get Template Detail'):
212+
response = self.client.get(
213+
reverse('test_template_detail', args=[t1.pk]), **auth
214+
)
215+
self.assertEqual(response.status_code, 200)
216+
217+
def test_view_django_model_permission_with_change_perm(self):
218+
user = self._get_user()
219+
user_permissions = Permission.objects.filter(codename='change_template')
220+
user.user_permissions.add(*user_permissions)
221+
user.organizations_dict # force caching
222+
org1 = self._get_org()
223+
auth, t1 = self._get_auth_template(user, org1)
224+
with self.subTest('Get Template List'):
225+
response = self.client.get(reverse('test_template_list'), **auth)
226+
self.assertEqual(response.status_code, 200)
227+
with self.subTest('Get Template Detail'):
228+
response = self.client.get(
229+
reverse('test_template_detail', args=[t1.pk]), **auth
230+
)
231+
self.assertEqual(response.status_code, 200)

tests/testapp/urls.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,4 +48,5 @@
4848
name='test_shelf_list_unauthorized_view',
4949
),
5050
path('template/', views.template_list, name='test_template_list',),
51+
path('template/<int:pk>/', views.template_detail, name='test_template_detail',),
5152
]

tests/testapp/views.py

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
import swapper
2-
from rest_framework.generics import ListAPIView, ListCreateAPIView
2+
from rest_framework.generics import (
3+
ListAPIView,
4+
ListCreateAPIView,
5+
RetrieveUpdateDestroyAPIView,
6+
)
37
from rest_framework.response import Response
48
from rest_framework.views import APIView
59

@@ -14,6 +18,7 @@
1418
)
1519
from openwisp_users.api.permissions import (
1620
BaseOrganizationPermission,
21+
DjangoModelPermissions,
1722
IsOrganizationManager,
1823
IsOrganizationMember,
1924
IsOrganizationOwner,
@@ -166,10 +171,23 @@ def get_queryset(self):
166171
return shelf.book_set.all()
167172

168173

169-
class TemplateListCreateView(ListCreateAPIView):
174+
class TemplateListCreateView(FilterByOrganizationManaged, ListCreateAPIView):
170175
serializer_class = TemplateSerializer
171176
authentication_classes = (BearerAuthentication,)
172-
permission_classes = (IsOrganizationMember,)
177+
permission_classes = (
178+
IsOrganizationMember,
179+
DjangoModelPermissions,
180+
)
181+
queryset = Template.objects.all()
182+
183+
184+
class TemplateDetailView(FilterByOrganizationManaged, RetrieveUpdateDestroyAPIView):
185+
serializer_class = TemplateSerializer
186+
authentication_classes = (BearerAuthentication,)
187+
permission_classes = (
188+
IsOrganizationMember,
189+
DjangoModelPermissions,
190+
)
173191
queryset = Template.objects.all()
174192

175193

@@ -188,3 +206,4 @@ class TemplateListCreateView(ListCreateAPIView):
188206
shelf_list_manager_view = ShelfListManagerView.as_view()
189207
shelf_list_owner_view = ShelfListOwnerView.as_view()
190208
template_list = TemplateListCreateView.as_view()
209+
template_detail = TemplateDetailView.as_view()

0 commit comments

Comments
 (0)