Merge branch 'master' of git://github.com/bwreilly/django-rest-framework into bwreilly-master

This commit is contained in:
Tom Christie 2013-09-10 20:21:15 +01:00
commit 75fb4b02b4
7 changed files with 216 additions and 36 deletions

View File

@ -42,6 +42,7 @@ The following packages are optional:
* [django-filter][django-filter] (0.5.4+) - Filtering support. * [django-filter][django-filter] (0.5.4+) - Filtering support.
* [django-oauth-plus][django-oauth-plus] (2.0+) and [oauth2][oauth2] (1.5.211+) - OAuth 1.0a support. * [django-oauth-plus][django-oauth-plus] (2.0+) and [oauth2][oauth2] (1.5.211+) - OAuth 1.0a support.
* [django-oauth2-provider][django-oauth2-provider] (0.2.3+) - OAuth 2.0 support. * [django-oauth2-provider][django-oauth2-provider] (0.2.3+) - OAuth 2.0 support.
* [django-guardian][django-guardian] (1.1.1+) - Object level permissions support.
**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. **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.

View File

@ -47,6 +47,12 @@ try:
except ImportError: except ImportError:
django_filters = None django_filters = None
# guardian is optional
try:
import guardian
except ImportError:
guardian = None
# cStringIO only if it's available, otherwise StringIO # cStringIO only if it's available, otherwise StringIO
try: try:

View File

@ -4,7 +4,7 @@ returned by list views.
""" """
from __future__ import unicode_literals from __future__ import unicode_literals
from django.db import models from django.db import models
from rest_framework.compat import django_filters, six from rest_framework.compat import django_filters, six, guardian
from functools import reduce from functools import reduce
import operator import operator
@ -23,6 +23,22 @@ class BaseFilterBackend(object):
raise NotImplementedError(".filter_queryset() must be overridden.") raise NotImplementedError(".filter_queryset() must be overridden.")
class ObjectPermissionReaderFilter(BaseFilterBackend):
"""
A filter backend that limits results to those where the requesting user
has read object level permissions.
"""
def __init__(self):
assert guardian, 'Using ObjectPermissionReaderFilter, but django-guardian is not installed'
def filter_queryset(self, request, queryset, view):
user = request.user
model_cls = queryset.model
model_name = model_cls._meta.module_name
permission = 'read_' + model_name
return guardian.shortcuts.get_objects_for_user(user, permission, queryset)
class DjangoFilterBackend(BaseFilterBackend): class DjangoFilterBackend(BaseFilterBackend):
""" """
A filter backend that uses django-filter. A filter backend that uses django-filter.

View File

@ -7,6 +7,7 @@ import warnings
SAFE_METHODS = ['GET', 'HEAD', 'OPTIONS'] SAFE_METHODS = ['GET', 'HEAD', 'OPTIONS']
from django.http import Http404
from rest_framework.compat import oauth2_provider_scope, oauth2_constants from rest_framework.compat import oauth2_provider_scope, oauth2_constants
@ -151,6 +152,50 @@ class DjangoModelPermissionsOrAnonReadOnly(DjangoModelPermissions):
authenticated_users_only = False authenticated_users_only = False
class DjangoObjectLevelModelPermissions(DjangoModelPermissions):
"""
The request is authenticated using `django.contrib.auth` permissions.
See: https://docs.djangoproject.com/en/dev/topics/auth/#permissions
It ensures that the user is authenticated, and has the appropriate
`add`/`change`/`delete` permissions on the object using .has_perms.
This permission can only be applied against view classes that
provide a `.model` or `.queryset` attribute.
"""
actions_map = {
'GET': ['read_%(model_name)s'],
'OPTIONS': ['read_%(model_name)s'],
'HEAD': ['read_%(model_name)s'],
'POST': ['add_%(model_name)s'],
'PUT': ['change_%(model_name)s'],
'PATCH': ['change_%(model_name)s'],
'DELETE': ['delete_%(model_name)s'],
}
def get_required_object_permissions(self, method, model_cls):
kwargs = {
'model_name': model_cls._meta.module_name
}
return [perm % kwargs for perm in self.actions_map[method]]
def has_object_permission(self, request, view, obj):
model_cls = getattr(view, 'model', None)
queryset = getattr(view, 'queryset', None)
if model_cls is None and queryset is not None:
model_cls = queryset.model
perms = self.get_required_object_permissions(request.method, model_cls)
user = request.user
check = user.has_perms(perms, obj)
if not check:
raise Http404
return user.has_perms(perms, obj)
class TokenHasReadWriteScope(BasePermission): class TokenHasReadWriteScope(BasePermission):
""" """
The request is authenticated as a user and the token used has the right scope The request is authenticated as a user and the token used has the right scope

View File

@ -123,6 +123,21 @@ else:
'provider.oauth2', 'provider.oauth2',
) )
# guardian is optional
try:
import guardian
except ImportError:
pass
else:
ANONYMOUS_USER_ID = -1
AUTHENTICATION_BACKENDS = (
'django.contrib.auth.backends.ModelBackend', # default
'guardian.backends.ObjectPermissionBackend',
)
INSTALLED_APPS += (
'guardian',
)
STATIC_URL = '/static/' STATIC_URL = '/static/'
PASSWORD_HASHERS = ( PASSWORD_HASHERS = (

View File

@ -1,18 +1,16 @@
from __future__ import unicode_literals from __future__ import unicode_literals
from django.contrib.auth.models import User, Permission from django.contrib.auth.models import User, Permission, Group
from django.db import models from django.db import models
from django.test import TestCase from django.test import TestCase
from rest_framework import generics, status, permissions, authentication, HTTP_HEADER_ENCODING from rest_framework import generics, status, permissions, authentication, HTTP_HEADER_ENCODING
from rest_framework.compat import guardian
from rest_framework.filters import ObjectPermissionReaderFilter
from rest_framework.test import APIRequestFactory from rest_framework.test import APIRequestFactory
from rest_framework.tests.models import BasicModel
import base64 import base64
factory = APIRequestFactory() factory = APIRequestFactory()
class BasicModel(models.Model):
text = models.CharField(max_length=100)
class RootView(generics.ListCreateAPIView): class RootView(generics.ListCreateAPIView):
model = BasicModel model = BasicModel
authentication_classes = [authentication.BasicAuthentication] authentication_classes = [authentication.BasicAuthentication]
@ -144,45 +142,136 @@ class ModelPermissionsIntegrationTests(TestCase):
self.assertEqual(list(response.data['actions'].keys()), ['PUT']) self.assertEqual(list(response.data['actions'].keys()), ['PUT'])
class OwnerModel(models.Model): class BasicPermModel(models.Model):
text = models.CharField(max_length=100) text = models.CharField(max_length=100)
owner = models.ForeignKey(User)
class Meta:
app_label = 'tests'
permissions = (
('read_basicpermmodel', 'Can view basic perm model'),
# add, change, delete built in to django
)
class IsOwnerPermission(permissions.BasePermission): class ObjectPermissionInstanceView(generics.RetrieveUpdateDestroyAPIView):
def has_object_permission(self, request, view, obj): model = BasicPermModel
return request.user == obj.owner
class OwnerInstanceView(generics.RetrieveUpdateDestroyAPIView):
model = OwnerModel
authentication_classes = [authentication.BasicAuthentication] authentication_classes = [authentication.BasicAuthentication]
permission_classes = [IsOwnerPermission] permission_classes = [permissions.DjangoObjectLevelModelPermissions]
object_permissions_view = ObjectPermissionInstanceView.as_view()
owner_instance_view = OwnerInstanceView.as_view() class ObjectPermissionListView(generics.ListAPIView):
model = BasicPermModel
authentication_classes = [authentication.BasicAuthentication]
permission_classes = [permissions.DjangoObjectLevelModelPermissions]
object_permissions_list_view = ObjectPermissionListView.as_view()
if guardian:
from guardian.shortcuts import assign_perm
class ObjectPermissionsIntegrationTests(TestCase): class ObjectPermissionsIntegrationTests(TestCase):
""" """
Integration tests for the object level permissions API. Integration tests for the object level permissions API.
""" """
@classmethod
def setUpClass(cls):
# create users
create = User.objects.create_user
users = {
'fullaccess': create('fullaccess', 'fullaccess@example.com', 'password'),
'readonly': create('readonly', 'readonly@example.com', 'password'),
'writeonly': create('writeonly', 'writeonly@example.com', 'password'),
'deleteonly': create('deleteonly', 'deleteonly@example.com', 'password'),
}
# give everyone model level permissions, as we are not testing those
everyone = Group.objects.create(name='everyone')
model_name = BasicPermModel._meta.module_name
app_label = BasicPermModel._meta.app_label
f = '{0}_{1}'.format
perms = {
'read': f('read', model_name),
'change': f('change', model_name),
'delete': f('delete', model_name)
}
for perm in perms.values():
perm = '{0}.{1}'.format(app_label, perm)
assign_perm(perm, everyone)
everyone.user_set.add(*users.values())
cls.perms = perms
cls.users = users
def setUp(self): def setUp(self):
User.objects.create_user('not_owner', 'not_owner@example.com', 'password') perms = self.perms
user = User.objects.create_user('owner', 'owner@example.com', 'password') users = self.users
self.not_owner_credentials = basic_auth_header('not_owner', 'password') # appropriate object level permissions
self.owner_credentials = basic_auth_header('owner', 'password') readers = Group.objects.create(name='readers')
writers = Group.objects.create(name='writers')
deleters = Group.objects.create(name='deleters')
OwnerModel(text='foo', owner=user).save() model = BasicPermModel.objects.create(text='foo')
def test_owner_has_delete_permissions(self): assign_perm(perms['read'], readers, model)
request = factory.delete('/1', HTTP_AUTHORIZATION=self.owner_credentials) assign_perm(perms['change'], writers, model)
response = owner_instance_view(request, pk='1') assign_perm(perms['delete'], deleters, model)
readers.user_set.add(users['fullaccess'], users['readonly'])
writers.user_set.add(users['fullaccess'], users['writeonly'])
deleters.user_set.add(users['fullaccess'], users['deleteonly'])
self.credentials = {}
for user in users.values():
self.credentials[user.username] = basic_auth_header(user.username, 'password')
# Delete
def test_can_delete_permissions(self):
request = factory.delete('/1', HTTP_AUTHORIZATION=self.credentials['deleteonly'])
response = object_permissions_view(request, pk='1')
self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
def test_non_owner_does_not_have_delete_permissions(self): def test_cannot_delete_permissions(self):
request = factory.delete('/1', HTTP_AUTHORIZATION=self.not_owner_credentials) request = factory.delete('/1', HTTP_AUTHORIZATION=self.credentials['readonly'])
response = owner_instance_view(request, pk='1') response = object_permissions_view(request, pk='1')
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
# Update
def test_can_update_permissions(self):
request = factory.patch('/1', {'text': 'foobar'}, format='json',
HTTP_AUTHORIZATION=self.credentials['writeonly'])
response = object_permissions_view(request, pk='1')
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data.get('text'), 'foobar')
def test_cannot_update_permissions(self):
request = factory.patch('/1', {'text': 'foobar'}, format='json',
HTTP_AUTHORIZATION=self.credentials['deleteonly'])
response = object_permissions_view(request, pk='1')
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
# Read
def test_can_read_permissions(self):
request = factory.get('/1', HTTP_AUTHORIZATION=self.credentials['readonly'])
response = object_permissions_view(request, pk='1')
self.assertEqual(response.status_code, status.HTTP_200_OK)
def test_cannot_read_permissions(self):
request = factory.get('/1', HTTP_AUTHORIZATION=self.credentials['writeonly'])
response = object_permissions_view(request, pk='1')
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
# Read list
def test_can_read_list_permissions(self):
request = factory.get('/', HTTP_AUTHORIZATION=self.credentials['readonly'])
object_permissions_list_view.cls.filter_backends = (ObjectPermissionReaderFilter,)
response = object_permissions_list_view(request)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data[0].get('id'), 1)
def test_cannot_read_list_permissions(self):
request = factory.get('/', HTTP_AUTHORIZATION=self.credentials['writeonly'])
object_permissions_list_view.cls.filter_backends = (ObjectPermissionReaderFilter,)
response = object_permissions_list_view(request)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertListEqual(response.data, [])

View File

@ -25,6 +25,7 @@ deps = https://www.djangoproject.com/download/1.6a1/tarball/
django-oauth-plus==2.0 django-oauth-plus==2.0
oauth2==1.5.211 oauth2==1.5.211
django-oauth2-provider==0.2.4 django-oauth2-provider==0.2.4
django-guardian==1.1.1
[testenv:py2.6-django1.6] [testenv:py2.6-django1.6]
basepython = python2.6 basepython = python2.6
@ -34,6 +35,7 @@ deps = https://www.djangoproject.com/download/1.6a1/tarball/
django-oauth-plus==2.0 django-oauth-plus==2.0
oauth2==1.5.211 oauth2==1.5.211
django-oauth2-provider==0.2.4 django-oauth2-provider==0.2.4
django-guardian==1.1.1
[testenv:py3.3-django1.5] [testenv:py3.3-django1.5]
basepython = python3.3 basepython = python3.3
@ -55,6 +57,7 @@ deps = django==1.5
django-oauth-plus==2.0 django-oauth-plus==2.0
oauth2==1.5.211 oauth2==1.5.211
django-oauth2-provider==0.2.3 django-oauth2-provider==0.2.3
django-guardian==1.1.1
[testenv:py2.6-django1.5] [testenv:py2.6-django1.5]
basepython = python2.6 basepython = python2.6
@ -64,6 +67,7 @@ deps = django==1.5
django-oauth-plus==2.0 django-oauth-plus==2.0
oauth2==1.5.211 oauth2==1.5.211
django-oauth2-provider==0.2.3 django-oauth2-provider==0.2.3
django-guardian==1.1.1
[testenv:py2.7-django1.4] [testenv:py2.7-django1.4]
basepython = python2.7 basepython = python2.7
@ -73,6 +77,7 @@ deps = django==1.4.3
django-oauth-plus==2.0 django-oauth-plus==2.0
oauth2==1.5.211 oauth2==1.5.211
django-oauth2-provider==0.2.3 django-oauth2-provider==0.2.3
django-guardian==1.1.1
[testenv:py2.6-django1.4] [testenv:py2.6-django1.4]
basepython = python2.6 basepython = python2.6
@ -82,6 +87,7 @@ deps = django==1.4.3
django-oauth-plus==2.0 django-oauth-plus==2.0
oauth2==1.5.211 oauth2==1.5.211
django-oauth2-provider==0.2.3 django-oauth2-provider==0.2.3
django-guardian==1.1.1
[testenv:py2.7-django1.3] [testenv:py2.7-django1.3]
basepython = python2.7 basepython = python2.7
@ -91,6 +97,7 @@ deps = django==1.3.5
django-oauth-plus==2.0 django-oauth-plus==2.0
oauth2==1.5.211 oauth2==1.5.211
django-oauth2-provider==0.2.3 django-oauth2-provider==0.2.3
django-guardian==1.1.1
[testenv:py2.6-django1.3] [testenv:py2.6-django1.3]
basepython = python2.6 basepython = python2.6
@ -100,3 +107,4 @@ deps = django==1.3.5
django-oauth-plus==2.0 django-oauth-plus==2.0
oauth2==1.5.211 oauth2==1.5.211
django-oauth2-provider==0.2.3 django-oauth2-provider==0.2.3
django-guardian==1.1.1