mirror of
https://github.com/encode/django-rest-framework.git
synced 2025-03-03 19:00:17 +03:00
Merge branch 'master' of git://github.com/bwreilly/django-rest-framework into bwreilly-master
This commit is contained in:
commit
75fb4b02b4
|
@ -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.
|
||||||
|
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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.
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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 = (
|
||||||
|
|
|
@ -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, [])
|
8
tox.ini
8
tox.ini
|
@ -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
|
||||||
|
|
Loading…
Reference in New Issue
Block a user