Skip to content

Commit 12ef5f8

Browse files
committed
recursive DRF and rest_permission boolean permissions
1 parent 838bc2e commit 12ef5f8

File tree

5 files changed

+210
-88
lines changed

5 files changed

+210
-88
lines changed

example/tests/test_openapi.py

Lines changed: 140 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,21 @@
22
import pytest
33
from django.conf.urls import url
44
from django.test import RequestFactory, TestCase, override_settings
5+
from rest_condition import And, Not, Or
56
from rest_framework import VERSION as DRFVERSION
67
from rest_framework.authentication import BasicAuthentication, SessionAuthentication
8+
from rest_framework.permissions import (
9+
AllowAny,
10+
DjangoModelPermissions,
11+
IsAdminUser,
12+
IsAuthenticated
13+
)
714
from rest_framework.request import Request
815

916
from rest_framework_json_api.optional import OAuth2Authentication, TokenMatchesOASRequirements
17+
from rest_framework_json_api.views import ModelViewSet
1018

11-
from example import views
19+
from example import models, serializers, views
1220

1321
drf_version = tuple(int(x) for x in DRFVERSION.split('.'))
1422

@@ -806,40 +814,42 @@ def test_delete_request(self):
806814
}
807815

808816
@pytest.mark.skipif(TokenMatchesOASRequirements is None, reason="requires oauth")
809-
def test_schema_security(self):
817+
class OauthProtectedAuthorViewSet(views.AuthorViewSet):
818+
authentication_classes = (OAuth2Authentication, BasicAuthentication,
819+
SessionAuthentication)
820+
permission_classes = (TokenMatchesOASRequirements, IsAuthenticated)
821+
required_alternate_scopes = {
822+
'GET': [['scope1', 'scope2'], ['scope3', 'scope4']],
823+
}
824+
825+
oauth2_server = 'oauth.example.com'
826+
oauth2_config = {
827+
'authorization_endpoint': oauth2_server + '/authorize',
828+
'token_endpoint': oauth2_server + '/token',
829+
'scopes_supported': ['scope1', 'scope2', 'scope3', 'scope4'],
830+
'grant_types_supported': ['implicit', 'authorization_code', 'client_credentials',
831+
'password'],
832+
}
833+
834+
def test_schema_security_list(self):
810835
"""
811-
Checks for security object
812-
:return:
836+
Checks for security objects
813837
"""
814-
class OauthProtectedAuthorViewSet(views.AuthorViewSet):
815-
authentication_classes = (OAuth2Authentication, BasicAuthentication,
816-
SessionAuthentication)
817-
permission_classes = (TokenMatchesOASRequirements,)
818-
required_alternate_scopes = {
819-
'GET': [['scope1', 'scope2'], ['scope3', 'scope4']],
820-
}
838+
# TODO: also test permission combinations with rest_condition & DRF bitwise operators
821839

822-
oauth2_server = 'oauth.example.com'
823-
oauth2_config = {
824-
'authorization_endpoint': oauth2_server + '/authorize',
825-
'token_endpoint': oauth2_server + '/token',
826-
'scopes_supported': ['scope1', 'scope2', 'scope3', 'scope4'],
827-
'grant_types_supported': ['implicit', 'authorization_code', 'client_credentials',
828-
'password'],
829-
}
830840
path = '/authors/'
831841
method = 'GET'
832842

833843
view = create_view_with_kw(
834-
OauthProtectedAuthorViewSet,
844+
TestOperationIntrospection.OauthProtectedAuthorViewSet,
835845
method,
836846
create_request(path),
837847
{'get': 'list'}
838848
)
839849
inspector = AutoSchema()
840850
inspector.view = view
841851

842-
with override_settings(OAUTH2_CONFIG=oauth2_config):
852+
with override_settings(OAUTH2_CONFIG=TestOperationIntrospection.oauth2_config):
843853
operation = inspector.get_operation(path, method)
844854

845855
assert 'security' in operation
@@ -849,39 +859,73 @@ class OauthProtectedAuthorViewSet(views.AuthorViewSet):
849859
assert operation['security'][2] == {'basicAuth': []}
850860
assert operation['security'][3] == {'cookieAuth': []}
851861

852-
# TODO: figure these out
853-
# def test_retrieve_relationships(self):
854-
# path = '/authors/{id}/relationships/bio/'
855-
# method = 'GET'
856-
#
857-
# view = create_view_with_kw(
858-
# views.AuthorRelationshipView,
859-
# method,
860-
# create_request(path),
861-
# {'get': 'retrieve'}
862-
# )
863-
# inspector = AutoSchema()
864-
# inspector.view = view
865-
#
866-
# operation = inspector.get_operation(path, method)
867-
# assert operation == {}
862+
def test_schema_security_drf_condition(self):
863+
"""
864+
Checks for security objects with DRF bitwise conditional operators
865+
"""
866+
class DRF_Cond_ViewSet(TestOperationIntrospection.OauthProtectedAuthorViewSet):
867+
# this is a crazy example just to make sure all the recursive code is covered
868+
permission_classes = [
869+
(IsAuthenticated & DjangoModelPermissions) |
870+
~(TokenMatchesOASRequirements & AllowAny),
871+
~AllowAny | (IsAdminUser & IsAuthenticated),
872+
(TokenMatchesOASRequirements & AllowAny) |
873+
(IsAuthenticated & DjangoModelPermissions),
874+
~TokenMatchesOASRequirements
875+
]
868876

869-
# def test_retrieve_related(self):
870-
# path = '/authors/{id}/{related_field}/'
871-
# method = 'GET'
872-
#
873-
# view = create_view_with_kw(
874-
# views.AuthorViewSet,
875-
# method,
876-
# create_request(path),
877-
# {'get': 'retrieve_related',
878-
# 'related_field': 'bio'}
879-
# )
880-
# inspector = AutoSchema()
881-
# inspector.view = view
882-
#
883-
# operation = inspector.get_operation(path, method)
884-
# assert operation == {}
877+
path = '/authors/'
878+
method = 'GET'
879+
880+
view = create_view_with_kw(
881+
DRF_Cond_ViewSet,
882+
method,
883+
create_request(path),
884+
{'get': 'list'}
885+
)
886+
inspector = AutoSchema()
887+
inspector.view = view
888+
889+
with override_settings(OAUTH2_CONFIG=TestOperationIntrospection.oauth2_config):
890+
operation = inspector.get_operation(path, method)
891+
892+
assert 'security' in operation
893+
assert {'oauth': ['scope1', 'scope2']} in operation['security']
894+
assert {'oauth': ['scope3', 'scope4']} in operation['security']
895+
assert {'basicAuth': []} in operation['security']
896+
assert {'cookieAuth': []} in operation['security']
897+
898+
def test_schema_security_rest_condition(self):
899+
"""
900+
Checks for security objects with rest_condition operator methods
901+
"""
902+
class Rest_Cond_ViewSet(TestOperationIntrospection.OauthProtectedAuthorViewSet):
903+
permission_classes = [
904+
Or(
905+
And(IsAuthenticated, DjangoModelPermissions),
906+
And(Not(TokenMatchesOASRequirements), AllowAny)),
907+
]
908+
909+
path = '/authors/'
910+
method = 'GET'
911+
912+
view = create_view_with_kw(
913+
Rest_Cond_ViewSet,
914+
method,
915+
create_request(path),
916+
{'get': 'list'}
917+
)
918+
inspector = AutoSchema()
919+
inspector.view = view
920+
921+
with override_settings(OAUTH2_CONFIG=TestOperationIntrospection.oauth2_config):
922+
operation = inspector.get_operation(path, method)
923+
924+
assert 'security' in operation
925+
assert {'oauth': ['scope1', 'scope2']} in operation['security']
926+
assert {'oauth': ['scope3', 'scope4']} in operation['security']
927+
assert {'basicAuth': []} in operation['security']
928+
assert {'cookieAuth': []} in operation['security']
885929

886930

887931
@override_settings(REST_FRAMEWORK={
@@ -901,3 +945,46 @@ def test_schema_construction(self):
901945
assert 'info' in schema
902946
assert 'paths' in schema
903947
assert 'components' in schema
948+
949+
# TODO: figure these out
950+
def test_schema_related(self):
951+
class AuthorBioViewSet(ModelViewSet):
952+
queryset = models.AuthorBio.objects.all()
953+
serializer_class = serializers.AuthorBioSerializer
954+
955+
patterns = [
956+
url(r'^authors/(?P<pk>[^/.]+)/(?P<related_field>\w+)/$',
957+
views.AuthorViewSet.as_view({'get': 'retrieve_related'}),
958+
name='author-related'),
959+
url(r'^bios/(?P<pk>[^/.]+)/$',
960+
AuthorBioViewSet,
961+
name='author-bio')
962+
]
963+
generator = SchemaGenerator(patterns=patterns)
964+
965+
request = create_request('/authors/123/bio/')
966+
schema = generator.get_schema(request=request)
967+
# TODO: finish this test
968+
print(schema)
969+
970+
# def test_retrieve_relationships(self):
971+
# path = '/authors/{id}/relationships/bio/'
972+
# method = 'GET'
973+
#
974+
# view = create_view_with_kw(
975+
# views.AuthorViewSet,
976+
# method,
977+
# create_request(path),
978+
# {'get': 'retrieve_related'}
979+
# )
980+
# inspector = AutoSchema()
981+
# inspector.view = view
982+
#
983+
# operation = inspector.get_operation(path, method)
984+
# assert 'responses' in operation
985+
# assert '200' in operation['responses']
986+
# resp = operation['responses']['200']['content']
987+
# data = resp['application/vnd.api+json']['schema']['properties']['data']
988+
# assert data['type'] == 'object'
989+
# assert data['required'] == ['type', 'id']
990+
# assert data['properties']['type'] == {'$ref': '#/components/schemas/type'}

rest_framework_json_api/optional.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,10 @@
66
except ImportError: # pragma: no cover
77
OAuth2Authentication = None
88
TokenMatchesOASRequirements = None
9+
10+
# DRF 3.9+ has native boolean conditions now.
11+
# But older code may use rest_condition (or other packages).
12+
try:
13+
from rest_condition import Condition
14+
except ImportError:
15+
Condition = None

rest_framework_json_api/schemas/openapi.py

Lines changed: 61 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -6,23 +6,19 @@
66
from django.utils.module_loading import import_string as import_class_from_dotted_path
77
from rest_framework import exceptions
88
from rest_framework.authentication import BasicAuthentication, SessionAuthentication
9-
from rest_framework.permissions import OperandHolder, SingleOperandHolder
9+
from rest_framework.permissions import AND, NOT, OR
1010
from rest_framework.relations import ManyRelatedField
1111
from rest_framework.schemas import openapi as drf_openapi
1212
from rest_framework.schemas.utils import is_list_view
1313

1414
from rest_framework_json_api import serializers
15-
from rest_framework_json_api.optional import OAuth2Authentication, TokenMatchesOASRequirements
15+
from rest_framework_json_api.optional import (
16+
Condition,
17+
OAuth2Authentication,
18+
TokenMatchesOASRequirements
19+
)
1620
from rest_framework_json_api.views import RelationshipView
1721

18-
# DRF 3.9+ has native boolean conditions now
19-
# But older code may use rest_condition or other packages.
20-
try:
21-
from rest_condition import Condition as rest_condition_Condition
22-
except ImportError:
23-
rest_condition_Condition = None
24-
25-
2622
#: static OAS 3.0 component definitions that are referenced by AutoSchema.
2723
JSONAPI_COMPONENTS = {
2824
'schemas': {
@@ -576,38 +572,68 @@ def _get_oauth_security(self, path, method):
576572
# TODO: add JWT and SAML2 bearer
577573
content = []
578574
# permission_classes can be a direct list of classes, or instances of Operands, etc.
579-
# TODO: this is kind of ugly. modularize it.
580-
for perm_class_or_condition in self.view.permission_classes:
581-
# check if DRF conditional operands were specified
582-
if isinstance(perm_class_or_condition, OperandHolder):
583-
if issubclass(perm_class_or_condition.op1_class, TokenMatchesOASRequirements):
584-
perm_class_instance = perm_class_or_condition.op1_class()
585-
elif issubclass(perm_class_or_condition.op2_class, TokenMatchesOASRequirements):
586-
perm_class_instance = perm_class_or_condition.op2_class()
587-
else:
588-
perm_class_instance = None
589-
elif isinstance(perm_class_or_condition, SingleOperandHolder):
590-
if issubclass(perm_class_or_condition.op1_class, TokenMatchesOASRequirements):
591-
perm_class_instance = perm_class_or_condition.op1_class()
592-
else:
593-
perm_class_instance = None
594-
# check for rest_condition.Condition
595-
elif (rest_condition_Condition and
596-
isinstance(perm_class_or_condition, rest_condition_Condition)):
597-
for cond in perm_class_or_condition.perms_or_conds:
598-
if issubclass(cond, TokenMatchesOASRequirements):
599-
perm_class_instance = cond()
600-
break
601-
else:
602-
perm_class_instance = perm_class_or_condition()
603-
if isinstance(perm_class_instance, TokenMatchesOASRequirements):
575+
for perm in self.view.permission_classes:
576+
if (
577+
isinstance(perm(), TokenMatchesOASRequirements) or
578+
self._drf_conditional_contains(perm(), TokenMatchesOASRequirements) or
579+
self._rest_cond_contains(perm(), TokenMatchesOASRequirements)
580+
):
604581
alt_scopes = self.view.required_alternate_scopes
605582
if method not in alt_scopes:
606583
continue
607584
for scopes in alt_scopes[method]:
608585
content.append({'oauth': scopes})
609586
return content
610587

588+
def _drf_conditional_contains(self, perm_inst, the_class):
589+
"""
590+
Recursively check if DRF conditional operands were specified.
591+
If there's any reference to `the_class` then return True.
592+
Don't care what the boolean logic is, just if there's an instance of the_class.
593+
"""
594+
binary = (AND, OR)
595+
unary = (NOT,)
596+
ops = binary + unary
597+
598+
if not isinstance(perm_inst, ops):
599+
return False
600+
601+
if isinstance(perm_inst, binary):
602+
if isinstance(perm_inst.op1, the_class):
603+
return True
604+
if isinstance(perm_inst.op2, the_class):
605+
return True
606+
if isinstance(perm_inst.op1, ops):
607+
if self._drf_conditional_contains(perm_inst.op1, the_class):
608+
return True
609+
if isinstance(perm_inst.op2, ops):
610+
if self._drf_conditional_contains(perm_inst.op2, the_class):
611+
return True
612+
elif isinstance(perm_inst, unary):
613+
if isinstance(perm_inst.op1, the_class):
614+
return True
615+
if isinstance(perm_inst.op1, ops):
616+
if self._drf_conditional_contains(perm_inst.op1, the_class):
617+
return True
618+
return False
619+
620+
def _rest_cond_contains(self, perm_inst, the_class):
621+
"""
622+
Recursively check if rest_condition conditional operands were specified.
623+
If there's any reference to `the_class` then return True.
624+
Don't care what the boolean logic is, just if there's an instance of the_class.
625+
"""
626+
if Condition is None or not isinstance(perm_inst, Condition):
627+
return False
628+
629+
for cond in perm_inst.perms_or_conds:
630+
if isinstance(cond(), the_class):
631+
return True
632+
if isinstance(cond(), Condition):
633+
if self._rest_cond_contains(cond, the_class):
634+
return True
635+
return False
636+
611637
def _get_include_parameters(self, path, method):
612638
"""
613639
includes parameter: https://jsonapi.org/format/#fetching-includes

setup.cfg

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ known_first_party = rest_framework_json_api
2222
# This is to "trick" isort into putting example below DJA imports.
2323
known_localfolder = example
2424
known_standard_library = mock
25+
known_third_party = rest_condition
2526
line_length = 100
2627
multi_line_output = 3
2728
skip=

tox.ini

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ deps =
1313
drfmaster: https://github.com/encode/django-rest-framework/archive/master.zip
1414
oauth12: django-oauth-toolkit>=1.2.0
1515
coreapi>=2.3.1
16+
rest-condition>=1.0.3
1617

1718
setenv =
1819
PYTHONPATH = {toxinidir}

0 commit comments

Comments
 (0)