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-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-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.

View File

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

View File

@ -4,7 +4,7 @@ returned by list views.
"""
from __future__ import unicode_literals
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
import operator
@ -23,6 +23,22 @@ class BaseFilterBackend(object):
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):
"""
A filter backend that uses django-filter.

View File

@ -7,6 +7,7 @@ import warnings
SAFE_METHODS = ['GET', 'HEAD', 'OPTIONS']
from django.http import Http404
from rest_framework.compat import oauth2_provider_scope, oauth2_constants
@ -151,6 +152,50 @@ class DjangoModelPermissionsOrAnonReadOnly(DjangoModelPermissions):
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):
"""
The request is authenticated as a user and the token used has the right scope

View File

@ -123,6 +123,21 @@ else:
'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/'
PASSWORD_HASHERS = (

View File

@ -1,18 +1,16 @@
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.test import TestCase
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.tests.models import BasicModel
import base64
factory = APIRequestFactory()
class BasicModel(models.Model):
text = models.CharField(max_length=100)
class RootView(generics.ListCreateAPIView):
model = BasicModel
authentication_classes = [authentication.BasicAuthentication]
@ -144,45 +142,136 @@ class ModelPermissionsIntegrationTests(TestCase):
self.assertEqual(list(response.data['actions'].keys()), ['PUT'])
class OwnerModel(models.Model):
class BasicPermModel(models.Model):
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):
def has_object_permission(self, request, view, obj):
return request.user == obj.owner
class OwnerInstanceView(generics.RetrieveUpdateDestroyAPIView):
model = OwnerModel
class ObjectPermissionInstanceView(generics.RetrieveUpdateDestroyAPIView):
model = BasicPermModel
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()
class ObjectPermissionsIntegrationTests(TestCase):
"""
Integration tests for the object level permissions API.
"""
if guardian:
from guardian.shortcuts import assign_perm
def setUp(self):
User.objects.create_user('not_owner', 'not_owner@example.com', 'password')
user = User.objects.create_user('owner', 'owner@example.com', 'password')
class ObjectPermissionsIntegrationTests(TestCase):
"""
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'),
}
self.not_owner_credentials = basic_auth_header('not_owner', 'password')
self.owner_credentials = basic_auth_header('owner', '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())
OwnerModel(text='foo', owner=user).save()
cls.perms = perms
cls.users = users
def test_owner_has_delete_permissions(self):
request = factory.delete('/1', HTTP_AUTHORIZATION=self.owner_credentials)
response = owner_instance_view(request, pk='1')
self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
def setUp(self):
perms = self.perms
users = self.users
def test_non_owner_does_not_have_delete_permissions(self):
request = factory.delete('/1', HTTP_AUTHORIZATION=self.not_owner_credentials)
response = owner_instance_view(request, pk='1')
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
# appropriate object level permissions
readers = Group.objects.create(name='readers')
writers = Group.objects.create(name='writers')
deleters = Group.objects.create(name='deleters')
model = BasicPermModel.objects.create(text='foo')
assign_perm(perms['read'], readers, model)
assign_perm(perms['change'], writers, model)
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)
def test_cannot_delete_permissions(self):
request = factory.delete('/1', HTTP_AUTHORIZATION=self.credentials['readonly'])
response = object_permissions_view(request, pk='1')
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
oauth2==1.5.211
django-oauth2-provider==0.2.4
django-guardian==1.1.1
[testenv:py2.6-django1.6]
basepython = python2.6
@ -34,6 +35,7 @@ deps = https://www.djangoproject.com/download/1.6a1/tarball/
django-oauth-plus==2.0
oauth2==1.5.211
django-oauth2-provider==0.2.4
django-guardian==1.1.1
[testenv:py3.3-django1.5]
basepython = python3.3
@ -55,6 +57,7 @@ deps = django==1.5
django-oauth-plus==2.0
oauth2==1.5.211
django-oauth2-provider==0.2.3
django-guardian==1.1.1
[testenv:py2.6-django1.5]
basepython = python2.6
@ -64,6 +67,7 @@ deps = django==1.5
django-oauth-plus==2.0
oauth2==1.5.211
django-oauth2-provider==0.2.3
django-guardian==1.1.1
[testenv:py2.7-django1.4]
basepython = python2.7
@ -73,6 +77,7 @@ deps = django==1.4.3
django-oauth-plus==2.0
oauth2==1.5.211
django-oauth2-provider==0.2.3
django-guardian==1.1.1
[testenv:py2.6-django1.4]
basepython = python2.6
@ -82,6 +87,7 @@ deps = django==1.4.3
django-oauth-plus==2.0
oauth2==1.5.211
django-oauth2-provider==0.2.3
django-guardian==1.1.1
[testenv:py2.7-django1.3]
basepython = python2.7
@ -91,6 +97,7 @@ deps = django==1.3.5
django-oauth-plus==2.0
oauth2==1.5.211
django-oauth2-provider==0.2.3
django-guardian==1.1.1
[testenv:py2.6-django1.3]
basepython = python2.6
@ -100,3 +107,4 @@ deps = django==1.3.5
django-oauth-plus==2.0
oauth2==1.5.211
django-oauth2-provider==0.2.3
django-guardian==1.1.1