Skip to content

Commit b41a6cf

Browse files
xordoquytomchristie
authored andcommitted
permissions: Allow permissions to be composed (#5753)
* permissions: Allow permissions to be composed Implement a system to compose permissions with and / or. This is performed by returning an `OperationHolder` instance that keeps the permission classes and type of composition (and / or). When called it will return a AND/OR instance that will then delegate the permission check to the operands. * permissions: Add documentation about composed permissions * Fix documentation typo in permissions
1 parent 8908934 commit b41a6cf

File tree

3 files changed

+127
-0
lines changed

3 files changed

+127
-0
lines changed

docs/api-guide/permissions.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,27 @@ Or, if you're using the `@api_view` decorator with function based views.
102102

103103
__Note:__ when you set new permission classes through class attribute or decorators you're telling the view to ignore the default list set over the __settings.py__ file.
104104

105+
Provided they inherit from `rest_framework.permissions.BasePermission`, permissions can be composed using standard Python bitwise operators. For example, `IsAuthenticatedOrReadOnly` could be written:
106+
107+
from rest_framework.permissions import BasePermission, IsAuthenticated
108+
from rest_framework.response import Response
109+
from rest_framework.views import APIView
110+
111+
class ReadOnly(BasePermission):
112+
def has_permission(self, request, view):
113+
return request.method in SAFE_METHODS
114+
115+
class ExampleView(APIView):
116+
permission_classes = (IsAuthenticated|ReadOnly)
117+
118+
def get(self, request, format=None):
119+
content = {
120+
'status': 'request was permitted'
121+
}
122+
return Response(content)
123+
124+
__Note:__ it only supports & -and- and | -or-.
125+
105126
---
106127

107128
# API Reference

rest_framework/permissions.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,76 @@
44
from __future__ import unicode_literals
55

66
from django.http import Http404
7+
from django.utils import six
78

89
from rest_framework import exceptions
910

1011
SAFE_METHODS = ('GET', 'HEAD', 'OPTIONS')
1112

1213

14+
class OperandHolder:
15+
def __init__(self, operator_class, op1_class, op2_class):
16+
self.operator_class = operator_class
17+
self.op1_class = op1_class
18+
self.op2_class = op2_class
19+
20+
def __call__(self, *args, **kwargs):
21+
op1 = self.op1_class(*args, **kwargs)
22+
op2 = self.op2_class(*args, **kwargs)
23+
return self.operator_class(op1, op2)
24+
25+
26+
class AND:
27+
def __init__(self, op1, op2):
28+
self.op1 = op1
29+
self.op2 = op2
30+
31+
def has_permission(self, request, view):
32+
return (
33+
self.op1.has_permission(request, view) &
34+
self.op2.has_permission(request, view)
35+
)
36+
37+
def has_object_permission(self, request, view, obj):
38+
return (
39+
self.op1.has_object_permission(request, view, obj) &
40+
self.op2.has_object_permission(request, view, obj)
41+
)
42+
43+
44+
class OR:
45+
def __init__(self, op1, op2):
46+
self.op1 = op1
47+
self.op2 = op2
48+
49+
def has_permission(self, request, view):
50+
return (
51+
self.op1.has_permission(request, view) |
52+
self.op2.has_permission(request, view)
53+
)
54+
55+
def has_object_permission(self, request, view, obj):
56+
return (
57+
self.op1.has_object_permission(request, view, obj) |
58+
self.op2.has_object_permission(request, view, obj)
59+
)
60+
61+
62+
class BasePermissionMetaclass(type):
63+
def __and__(cls, other):
64+
return OperandHolder(AND, cls, other)
65+
66+
def __or__(cls, other):
67+
return OperandHolder(OR, cls, other)
68+
69+
def __rand__(cls, other):
70+
return OperandHolder(AND, other, cls)
71+
72+
def __ror__(cls, other):
73+
return OperandHolder(OR, other, cls)
74+
75+
76+
@six.add_metaclass(BasePermissionMetaclass)
1377
class BasePermission(object):
1478
"""
1579
A base class from which all permission classes should inherit.

tests/test_permissions.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -540,3 +540,45 @@ def test_permission_denied_for_object_with_custom_detail(self):
540540
detail = response.data.get('detail')
541541
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
542542
self.assertEqual(detail, self.custom_message)
543+
544+
545+
class FakeUser:
546+
def __init__(self, auth=False):
547+
self.is_authenticated = auth
548+
549+
550+
class PermissionsCompositionTests(TestCase):
551+
def test_and_false(self):
552+
request = factory.get('/1', format='json')
553+
request.user = FakeUser(auth=False)
554+
composed_perm = permissions.IsAuthenticated & permissions.AllowAny
555+
assert composed_perm().has_permission(request, None) is False
556+
557+
def test_and_true(self):
558+
request = factory.get('/1', format='json')
559+
request.user = FakeUser(auth=True)
560+
composed_perm = permissions.IsAuthenticated & permissions.AllowAny
561+
assert composed_perm().has_permission(request, None) is True
562+
563+
def test_or_false(self):
564+
request = factory.get('/1', format='json')
565+
request.user = FakeUser(auth=False)
566+
composed_perm = permissions.IsAuthenticated | permissions.AllowAny
567+
assert composed_perm().has_permission(request, None) is True
568+
569+
def test_or_true(self):
570+
request = factory.get('/1', format='json')
571+
request.user = FakeUser(auth=True)
572+
composed_perm = permissions.IsAuthenticated | permissions.AllowAny
573+
assert composed_perm().has_permission(request, None) is True
574+
575+
def test_several_levels(self):
576+
request = factory.get('/1', format='json')
577+
request.user = FakeUser(auth=True)
578+
composed_perm = (
579+
permissions.IsAuthenticated &
580+
permissions.IsAuthenticated &
581+
permissions.IsAuthenticated &
582+
permissions.IsAuthenticated
583+
)
584+
assert composed_perm().has_permission(request, None) is True

0 commit comments

Comments
 (0)