8000 Merge branch 'master' of git://github.com/bwreilly/django-rest-framew… · alumni/django-rest-framework@75fb4b0 · GitHub
[go: up one dir, main page]

Skip to content

Commit 75fb4b0

Browse files
committed
Merge branch 'master' of git://github.com/bwreilly/django-rest-framework into bwreilly-master
2 parents f5c3492 + 23fc9dd commit 75fb4b0

File tree

7 files changed

+223
-43
lines changed

7 files changed

+223
-43
lines changed

docs/index.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ The following packages are optional:
4242
* [django-filter][django-filter] (0.5.4+) - Filtering support.
4343
* [django-oauth-plus][django-oauth-plus] (2.0+) and [oauth2][oauth2] (1.5.211+) - OAuth 1.0a support.
4444
* [django-oauth2-provider][django-oauth2-provider] (0.2.3+) - OAuth 2.0 support.
45+
* [django-guardian][django-guardian] (1.1.1+) - Object level permissions support.
4546

4647
**Note**: The `oauth2` Python package is badly misnamed, and actually provides OAuth 1.0a support. Also note that packages required for both OAuth 1.0a, and OAuth 2.0 are not yet Python 3 compatible.
4748

rest_framework/compat.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,12 @@
4747
except ImportError:
4848
django_filters = None
4949

50+
# guardian is optional
51+
try:
52+
import guardian
53+
except ImportError:
54+
guardian = None
55+
5056

5157
# cStringIO only if it's available, otherwise StringIO
5258
try:

rest_framework/filters.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
"""
55
from __future__ import unicode_literals
66
from django.db import models
7-
from rest_framework.compat import django_filters, six
7+
from rest_framework.compat import django_filters, six, guardian
88
from functools import reduce
99
import operator
1010

@@ -23,6 +23,22 @@ def filter_queryset(self, request, queryset, view):
2323
raise NotImplementedError(".filter_queryset() must be overridden.")
2424

2525

26+
class ObjectPermissionReaderFilter(BaseFilterBackend):
27+
"""
28+
A filter backend that limits results to those where the requesting user
29+
has read object level permissions.
30+
"""
31+
def __init__(self):
32+
assert guardian, 'Using ObjectPermissionReaderFilter, but django-guardian is not installed'
33+
34+
def filter_queryset(self, request, queryset, view):
35+
user = request.user
36+
model_cls = queryset.model
37+
model_name = model_cls._meta.module_name
38+
permission = 'read_' + model_name
39+
return guardian.shortcuts.get_objects_for_user(user, permission, queryset)
40+
41+
2642
class DjangoFilterBackend(BaseFilterBackend):
2743
"""
2844
A filter backend that uses django-filter.

rest_framework/permissions.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
SAFE_METHODS = ['GET', 'HEAD', 'OPTIONS']
99

10+
from django.http import Http404
1011
from rest_framework.compat import oauth2_provider_scope, oauth2_constants
1112

1213

@@ -151,6 +152,50 @@ class DjangoModelPermissionsOrAnonReadOnly(DjangoModelPermissions):
151152
authenticated_users_only = False
152153

153154

155+
class DjangoObjectLevelModelPermissions(DjangoModelPermissions):
156+
"""
157+
The request is authenticated using `django.contrib.auth` permissions.
158+
See: https://docs.djangoproject.com/en/dev/topics/auth/#permissions
159+
160+
It ensures that the user is authenticated, and has the appropriate
161+
`add`/`change`/`delete` permissions on the object using .has_perms.
162+
163+
This permission can only be applied against view classes that
164+
provide a `.model` or `.queryset` attribute.
165+
"""
166+
167+
actions_map = {
168+
'GET': ['read_%(model_name)s'],
169+
'OPTIONS': ['read_%(model_name)s'],
170+
'HEAD': ['read_%(model_name)s'],
171+
'POST': ['add_%(model_name)s'],
172+
'PUT': ['change_%(model_name)s'],
173+
'PATCH': ['change_%(model_name)s'],
174+
'DELETE': ['delete_%(model_name)s'],
175+
}
176+
177+
def get_required_object_permissions(self, method, model_cls):
178+
kwargs = {
179+
'model_name': model_cls._meta.module_name
180+
}
181+
return [perm % kwargs for perm in self.actions_map[method]]
182+
183+
def has_object_permission(self, request, view, obj):
184+
model_cls = getattr(view, 'model', None)
185+
queryset = getattr(view, 'queryset', None)
186+
187+
if model_cls is None and queryset is not None:
188+
model_cls = queryset.model
189+
190+
perms = self.get_required_object_permissions(request.method, model_cls)
191+
user = request.user
192+
193+
check = user.has_perms(perms, obj)
194+
if not check:
195+
raise Http404
196+
return user.has_perms(perms, obj)
197+
198+
154199
class TokenHasReadWriteScope(BasePermission):
155200
"""
156201
The request is authenticated as a user and the token used has the right scope

rest_framework/runtests/settings.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,21 @@
123123
'provider.oauth2',
124124
)
125125

126+
# guardian is optional
127+
try:
128+
import guardian
129+
except ImportError:
130+
pass
131+
else:
132+
ANONYMOUS_USER_ID = -1
133+
AUTHENTICATION_BACKENDS = (
134+
'django.contrib.auth.backends.ModelBackend', # default
135+
'guardian.backends.ObjectPermissionBackend',
136+
)
137+
INSTALLED_APPS += (
138+
'guardian',
139+
)
140+
126141
STATIC_URL = '/static/'
127142

128143
PASSWORD_HASHERS = (

rest_framework/tests/test_permissions.py

Lines changed: 131 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,16 @@
11
from __future__ import unicode_literals
2-
from django.contrib.auth.models import User, Permission
2+
from django.contrib.auth.models import User, Permission, Group
33
from django.db import models
44
from django.test import TestCase
55
from rest_framework import generics, status, permissions, authentication, HTTP_HEADER_ENCODING
6+
from rest_framework.compat import guardian
7+
from rest_framework.filters import ObjectPermissionReaderFilter
68
from rest_framework.test import APIRequestFactory
9+
from rest_framework.tests.models import BasicModel
710
import base64
811

912
factory = APIRequestFactory()
1013

11-
12-
class BasicModel(models.Model):
13-
text = models.CharField(max_length=100)
14-
15-
1614
class RootView(generics.ListCreateAPIView):
1715
model = BasicModel
1816
authentication_classes = [authentication.BasicAuthentication]
@@ -144,45 +142,136 @@ def test_options_updateonly(self):
144142
self.assertEqual(list(response.data['actions'].keys()), ['PUT'])
145143

146144

147-
class OwnerModel(models.Model):
145+
class BasicPermModel(models.Model):
148146
text = models.CharField(max_length=100)
149-
owner = models.ForeignKey(User)
150-
151147

152-
class IsOwnerPermission(permissions.BasePermission):
153-
def has_object_permission(self, request, view, obj):
154-
return request.user == obj.owner
148+
class Meta:
149+
app_label = 'tests'
150+
permissions = (
151+
('read_basicpermmodel', 'Can view basic perm model'),
152+
# add, change, delete built in to django
153+
)
155154

156-
157-
class OwnerInstanceView(generics.RetrieveUpdateDestroyAPIView):
158-
model = OwnerModel
155+
class ObjectPermissionInstanceView(generics.RetrieveUpdateDestroyAPIView):
156+
model = BasicPermModel
159157
authentication_classes = [authentication.BasicAuthentication]
160-
permission_classes = [IsOwnerPermission]
161-
162-
163-
owner_instance_view = OwnerInstanceView.as_view()
164-
158+
F438 permission_classes = [permissions.DjangoObjectLevelModelPermissions]
165159

166-
class ObjectPermissionsIntegrationTests(TestCase):
167-
"""
168-
Integration tests for the object level permissions API.
169-
"""
160+
object_permissions_view = ObjectPermissionInstanceView.as_view()
170161

171-
def setUp(self):
172-
User.objects.create_user('not_owner', 'not_owner@example.com', 'password')
173-
user = User.objects.create_user('owner', 'owner@example.com', 'password')
174-
175-
self.not_owner_credentials = basic_auth_header('not_owner', 'password')
176-
self.owner_credentials = basic_auth_header('owner', 'password')
177-
178-
OwnerModel(text='foo', owner=user).save()
179-
180-
def test_owner_has_delete_permissions(self):
181-
request = factory.delete('/1', HTTP_AUTHORIZATION=self.owner_credentials)
182-
response = owner_instance_view(request, pk='1')
183-
self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
184-
185-
def test_non_owner_does_not_have_delete_permissions(self):
186-
request = factory.delete('/1', HTTP_AUTHORIZATION=self.not_owner_credentials)
187-
response = owner_instance_view(request, pk='1')
188-
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
162+
class ObjectPermissionListView(generics.ListAPIView):
163+
model = BasicPermModel
164+
authentication_classes = [authentication.BasicAuthentication]
165+
permission_classes = [permissions.DjangoObjectLevelModelPermissions]
166+
167+
object_permissions_list_view = ObjectPermissionListView.as_view()
168+
169+
if guardian:
170+
from guardian.shortcuts import assign_perm
171+
172+
class ObjectPermissionsIntegrationTests(TestCase):
173+
"""
174+
Integration tests for the object level permissions API.
175+
"""
176+
@classmethod
177+
def setUpClass(cls):
178+
# create users
179+
create = User.objects.create_user
180+
users = {
181+
'fullaccess': create('fullaccess', 'fullaccess@example.com', 'password'),
182+
'readonly': create('readonly', 'readonly@example.com', 'password'),
183+
'writeonly': create('writeonly', 'writeonly@example.com', 'password'),
184+
'deleteonly': create('deleteonly', 'deleteonly@example.com', 'password'),
185+
}
186+
187+
# give everyone model level permissions, as we are not testing those
188+
everyone = Group.objects.create(name='everyone')
189+
model_name = BasicPermModel._meta.module_name
190+
app_label = BasicPermModel._meta.app_label
191+
f = '{0}_{1}'.format
192+
perms = {
193+
'read': f('read', model_name),
194+
'change': f('change', model_name),
195+
'delete': f('delete', model_name)
196+
}
197+
for perm in perms.values():
198+
perm = '{0}.{1}'.format(app_label, perm)
199+
assign_perm(perm, everyone)
200+
everyone.user_set.add(*users.values())
201+
202+
cls.perms = perms
203+
cls.users = users
204+
205+
def setUp(self):
206+
perms = self.perms
207+
users = self.users
208+
209+
# appropriate object level permissions
210+
readers = Group.objects.create(name='readers')
211+
writers = Group.objects.create(name='writers')
212+
deleters = Group.objects.create(name='deleters')
213+
214+
model = BasicPermModel.objects.create(text='foo')
215+
216+
assign_perm(perms['read'], readers, model)
217+
assign_perm(perms['change'], writers, model)
218+
assign_perm(perms['delete'], deleters, model)
219+
220+
readers.user_set.add(users['fullaccess'], users['readonly'])
221+
writers.user_set.add(users['fullaccess'], users['writeonly'])
222+
deleters.user_set.add(users['fullaccess'], users['deleteonly'])
223+
224+
self.credentials = {}
225+
for user in users.values():
226+
self.credentials[user.username] = basic_auth_header(user.username, 'password')
227+
228+
# Delete
229+
def test_can_delete_permissions(self):
230+
request = factory.delete('/1', HTTP_AUTHORIZATION=self.credentials['deleteonly'])
231+
response = object_permissions_view(request, pk='1')
232+
self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
233+
234+
def test_cannot_delete_permissions(self):
235+
request = factory.delete('/1', HTTP_AUTHORIZATION=self.credentials['readonly'])
236+
response = object_permissions_view(request, pk='1')
237+
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
238+
239+
# Update
240+
def test_can_update_permissions(self):
241+
request = factory.patch('/1', {'text': 'foobar'}, format='json',
242+
HTTP_AUTHORIZATION=self.credentials['writeonly'])
243+
response = object_permissions_view(request, pk='1')
244+
self.assertEqual(response.status_code, status.HTTP_200_OK)
245+
self.assertEqual(response.data.get('text'), 'foobar')
246+
247+
def test_cannot_update_permissions(self):
248+
request = factory.patch('/1', {'text': 'foobar'}, format='json',
249+
HTTP_AUTHORIZATION=self.credentials['deleteonly'])
250+
response = object_permissions_view(request, pk='1')
251+
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
252+
253+
# Read
254+
def test_can_read_permissions(self):
255+
request = factory.get('/1', HTTP_AUTHORIZATION=self.credentials['readonly'])
256+
response = object_permissions_view(request, pk='1')
257+
self.assertEqual(response.status_code, status.HTTP_200_OK)
258+
259+
def test_cannot_read_permissions(self):
260+
request = factory.get('/1', HTTP_AUTHORIZATION=self.credentials['writeonly'])
261+
response = object_permissions_view(request, pk='1')
262+
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
263+
264+
# Read list
265+
def test_can_read_list_permissions(self):
266+
request = factory.get('/', HTTP_AUTHORIZATION=self.credentials['readonly'])
267+
object_permissions_list_view.cls.filter_backends = (ObjectPermissionReaderFilter,)
268+
response = object_permissions_list_view(request)
269+
self.assertEqual(response.status_code, status.HTTP_200_OK)
270+
self.assertEqual(response.data[0].get('id'), 1)
271+
272+
def test_cannot_read_list_permissions(self):
273+
request = factory.get('/', HTTP_AUTHORIZATION=self.credentials['writeonly'])
274+
object_permissions_list_view.cls.filter_backends = (ObjectPermissionReaderFilter,)
275+
response = object_permissions_list_view(request)
276+
self.assertEqual(response.status_code, status.HTTP_200_OK)
277+
self.assertListEqual(response.data, [])

tox.ini

Lines changed: 8 additions & 0 deletions
E5E0
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ deps = https://www.djangoproject.com/download/1.6a1/tarball/
2525
django-oauth-plus==2.0
2626
oauth2==1.5.211
2727
django-oauth2-provider==0.2.4
28+
django-guardian==1.1.1
2829

2930
[testenv:py2.6-django1.6]
3031
basepython = python2.6
@@ -34,6 +35,7 @@ deps = https://www.djangoproject.com/download/1.6a1/tarball/
3435
django-oauth-plus==2.0
3536
oauth2==1.5.211
3637
django-oauth2-provider==0.2.4
38+
django-guardian==1.1.1
3739

3840
[testenv:py3.3-django1.5]
3941
basepython = python3.3
@@ -55,6 +57,7 @@ deps = django==1.5
5557
django-oauth-plus==2.0
5658
oauth2==1.5.211
5759
django-oauth2-provider==0.2.3
60+
django-guardian==1.1.1
5861

5962
[testenv:py2.6-django1.5]
6063
basepython = python2.6
@@ -64,6 +67,7 @@ deps = django==1.5
6467
django-oauth-plus==2.0
6568
oauth2==1.5.211
6669
django-oauth2-provider==0.2.3
70+
django-guardian==1.1.1
6771

6872
[testenv:py2.7-django1.4]
6973
basepython = python2.7
@@ -73,6 +77,7 @@ deps = django==1.4.3
7377
django-oauth-plus==2.0
7478
oauth2==1.5.211
7579
django-oauth2-provider==0.2.3
80+
django-guardian==1.1.1
7681

7782
[testenv:py2.6-django1.4]
7883
basepython = python2.6
@@ -82,6 +87,7 @@ deps = django==1.4.3
8287
django-oauth-plus==2.0
8388
oauth2==1.5.211
8489
django-oauth2-provider==0.2.3
90+
django-guardian==1.1.1
8591

8692
[testenv:py2.7-django1.3]
8793
basepython = python2.7
@@ -91,6 +97,7 @@ deps = django==1.3.5
9197
django-oauth-plus==2.0
9298
oauth2==1.5.211
9399
django-oauth2-provider==0.2.3
100+
django-guardian==1.1.1
94101

95102
[testenv:py2.6-django1.3]
96103
basepython = python2.6
@@ -100,3 +107,4 @@ deps = django==1.3.5
100107
django-oauth-plus==2.0
101108
oauth2==1.5.211
102109
django-oauth2-provider==0.2.3
110+
django-guardian==1.1.1

0 commit comments

Comments
 (0)
0