Skip to content

Commit 6c81f5c

Browse files
committed
Merge branch 'release/19.23.0'
2 parents 1b21f10 + 9119622 commit 6c81f5c

22 files changed

+1047
-157
lines changed

CHANGELOG

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@
22

33
We follow the CalVer (https://calver.org/) versioning scheme: YY.MINOR.MICRO.
44

5+
19.23.0 (2019-08-19)
6+
===================
7+
- Represents scopes as an m2m field on personal access tokens instead of a CharField
8+
- APIv2.17 treats scopes as relationships on tokens instead of attributes. Earlier
9+
API versions still serialize scopes as attributes.
10+
511
19.22.0 (2019-08-14)
612
===================
713
- APIv2: Editable registrations

api/base/settings/defaults.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,7 @@
167167
'2.14',
168168
'2.15',
169169
'2.16',
170+
'2.17',
170171
),
171172
'DEFAULT_FILTER_BACKENDS': ('api.base.filters.OSFOrderingFilter',),
172173
'DEFAULT_PAGINATION_CLASS': 'api.base.pagination.JSONAPIPagination',

api/scopes/permissions.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
from rest_framework import permissions
2-
from api.scopes.serializers import Scope
2+
from osf.models.oauth import ApiOAuth2Scope
33

44
class IsPublicScope(permissions.BasePermission):
55

66
def has_object_permission(self, request, view, obj):
7-
assert isinstance(obj, Scope), 'obj must be an Scope got {}'.format(obj)
7+
assert isinstance(obj, ApiOAuth2Scope), 'obj must be an ApiOAuth2Scope got {}'.format(obj)
88
return obj.is_public

api/scopes/serializers.py

Lines changed: 4 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,19 @@
11
from rest_framework import serializers as ser
2-
from website import settings
3-
from urlparse import urljoin
42

53
from api.base.serializers import (
64
JSONAPISerializer,
75
LinksField,
86
)
97

10-
11-
class Scope(object):
12-
def __init__(self, id, scope):
13-
scope = scope or {}
14-
self.id = id
15-
self.description = scope.description
16-
self.is_public = scope.is_public
17-
18-
def absolute_url(self):
19-
return urljoin(settings.API_DOMAIN, '/v2/scopes/{}/'.format(self.id))
8+
# With this API version, scopes are a M2M field on ApiOAuth2PersonalToken, and
9+
# serialized as relationship.
10+
SCOPES_RELATIONSHIP_VERSION = '2.17'
2011

2112
class ScopeSerializer(JSONAPISerializer):
2213

2314
filterable_fields = frozenset(['id'])
2415

25-
id = ser.CharField(read_only=True)
16+
id = ser.CharField(read_only=True, source='name')
2617
description = ser.CharField(read_only=True)
2718
links = LinksField({'self': 'get_absolute_url'})
2819

api/scopes/views.py

Lines changed: 10 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
from rest_framework import generics, permissions as drf_permissions
22
from rest_framework.exceptions import NotFound
3-
from framework.auth.oauth_scopes import CoreScopes, public_scopes
3+
from framework.auth.oauth_scopes import CoreScopes
44

55
from api.base.filters import ListFilterMixin
66
from api.base import permissions as base_permissions
7-
from api.scopes.serializers import ScopeSerializer, Scope
7+
from api.scopes.serializers import ScopeSerializer
88
from api.scopes.permissions import IsPublicScope
99
from api.base.views import JSONAPIBaseView
1010
from api.base.pagination import MaxSizePagination
11+
from osf.models.oauth import ApiOAuth2Scope
1112

1213

1314
class ScopeDetail(JSONAPIBaseView, generics.RetrieveAPIView):
@@ -30,14 +31,14 @@ class ScopeDetail(JSONAPIBaseView, generics.RetrieveAPIView):
3031
# overrides RetrieveAPIView
3132
def get_object(self):
3233
id = self.kwargs[self.lookup_url_kwarg]
33-
scope_item = public_scopes.get(id, None)
34-
if scope_item:
35-
scope = Scope(id=id, scope=scope_item)
36-
self.check_object_permissions(self.request, scope)
37-
return scope
38-
else:
34+
try:
35+
scope = ApiOAuth2Scope.objects.get(name=id)
36+
except ApiOAuth2Scope.DoesNotExist:
3937
raise NotFound
4038

39+
self.check_object_permissions(self.request, scope)
40+
return scope
41+
4142

4243
class ScopeList(JSONAPIBaseView, generics.ListAPIView, ListFilterMixin):
4344
"""Private endpoint for gathering scope information. Do not expect this to be stable.
@@ -59,11 +60,7 @@ class ScopeList(JSONAPIBaseView, generics.ListAPIView, ListFilterMixin):
5960
ordering = ('id', ) # default ordering
6061

6162
def get_default_queryset(self):
62-
scopes = []
63-
for key, value in public_scopes.items():
64-
if value.is_public:
65-
scopes.append(Scope(id=key, scope=value))
66-
return scopes
63+
return ApiOAuth2Scope.objects.filter(is_public=True)
6764

6865
def get_queryset(self):
6966
return self.get_queryset_from_request()

api/tokens/serializers.py

Lines changed: 101 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,47 @@
11
from rest_framework import serializers as ser
22
from rest_framework import exceptions
33

4-
from framework.auth.oauth_scopes import public_scopes
54
from osf.exceptions import ValidationError
6-
from osf.models import ApiOAuth2PersonalToken
5+
from osf.models import ApiOAuth2PersonalToken, ApiOAuth2Scope
76

87
from api.base.exceptions import format_validation_error
9-
from api.base.serializers import JSONAPISerializer, LinksField, IDField, TypeField
8+
from api.base.serializers import JSONAPISerializer, LinksField, IDField, TypeField, RelationshipField, StrictVersion
9+
from api.scopes.serializers import SCOPES_RELATIONSHIP_VERSION
10+
11+
12+
class TokenScopesRelationshipField(RelationshipField):
13+
14+
def to_internal_value(self, data):
15+
return {'scopes': data}
1016

1117

1218
class ApiOAuth2PersonalTokenSerializer(JSONAPISerializer):
1319
"""Serialize data about a registered personal access token"""
1420

21+
def __init__(self, *args, **kwargs):
22+
super(ApiOAuth2PersonalTokenSerializer, self).__init__(*args, **kwargs)
23+
24+
request = kwargs['context']['request']
25+
26+
# Dynamically adding scopes field here, depending on the version
27+
if expect_scopes_as_relationships(request):
28+
field = TokenScopesRelationshipField(
29+
related_view='tokens:token-scopes-list',
30+
related_view_kwargs={'_id': '<_id>'},
31+
always_embed=True,
32+
read_only=False,
33+
)
34+
self.fields['scopes'] = field
35+
self.fields['owner'] = RelationshipField(
36+
related_view='users:user-detail',
37+
related_view_kwargs={'user_id': '<owner._id>'},
38+
)
39+
# Making scopes embeddable
40+
self.context['embed']['scopes'] = self.context['view']._get_embed_partial('scopes', field)
41+
else:
42+
self.fields['scopes'] = ser.SerializerMethodField()
43+
self.fields['owner'] = ser.SerializerMethodField()
44+
1545
id = IDField(source='_id', read_only=True, help_text='The object ID for this token (automatically generated)')
1646
type = TypeField()
1747

@@ -20,17 +50,6 @@ class ApiOAuth2PersonalTokenSerializer(JSONAPISerializer):
2050
required=True,
2151
)
2252

23-
owner = ser.CharField(
24-
help_text='The user who owns this token',
25-
read_only=True, # Don't let user register a token in someone else's name
26-
source='owner._id',
27-
)
28-
29-
scopes = ser.CharField(
30-
help_text='Governs permissions associated with this token',
31-
required=True,
32-
)
33-
3453
token_id = ser.CharField(read_only=True, allow_blank=True)
3554

3655
class Meta:
@@ -40,6 +59,12 @@ class Meta:
4059
'html': 'absolute_url',
4160
})
4261

62+
def get_owner(self, obj):
63+
return obj.owner._id
64+
65+
def get_scopes(self, obj):
66+
return ' '.join([scope.name for scope in obj.scopes.all()])
67+
4368
def absolute_url(self, obj):
4469
return obj.absolute_url
4570

@@ -58,17 +83,21 @@ def to_representation(self, obj, envelope='data'):
5883
return data
5984

6085
def create(self, validated_data):
61-
validate_requested_scopes(validated_data)
86+
scopes = validate_requested_scopes(validated_data.pop('scopes', None))
87+
if not scopes:
88+
raise exceptions.ValidationError('Cannot create a token without scopes.')
6289
instance = ApiOAuth2PersonalToken(**validated_data)
6390
try:
6491
instance.save()
6592
except ValidationError as e:
6693
detail = format_validation_error(e)
6794
raise exceptions.ValidationError(detail=detail)
95+
for scope in scopes:
96+
instance.scopes.add(scope)
6897
return instance
6998

7099
def update(self, instance, validated_data):
71-
validate_requested_scopes(validated_data)
100+
scopes = validate_requested_scopes(validated_data.pop('scopes', None))
72101
assert isinstance(instance, ApiOAuth2PersonalToken), 'instance must be an ApiOAuth2PersonalToken'
73102

74103
instance.deactivate(save=False) # This will cause CAS to revoke the existing token but still allow it to be used in the future, new scopes will be updated properly at that time.
@@ -79,15 +108,66 @@ def update(self, instance, validated_data):
79108
continue
80109
else:
81110
setattr(instance, attr, value)
111+
if scopes:
112+
update_scopes(instance, scopes)
82113
try:
83114
instance.save()
84115
except ValidationError as e:
85116
detail = format_validation_error(e)
86117
raise exceptions.ValidationError(detail=detail)
87118
return instance
88119

89-
def validate_requested_scopes(validated_data):
90-
scopes_set = set(validated_data.get('scopes', '').split(' '))
91-
for scope in scopes_set:
92-
if scope not in public_scopes or not public_scopes[scope].is_public:
93-
raise exceptions.ValidationError('User requested invalid scope')
120+
121+
class ApiOAuth2PersonalTokenWritableSerializer(ApiOAuth2PersonalTokenSerializer):
122+
def __init__(self, *args, **kwargs):
123+
super(ApiOAuth2PersonalTokenWritableSerializer, self).__init__(*args, **kwargs)
124+
request = kwargs['context']['request']
125+
126+
# Dynamically overriding scopes field for early versions to make scopes writable via an attribute
127+
if not expect_scopes_as_relationships(request):
128+
self.fields['scopes'] = ser.CharField(write_only=True, required=False)
129+
130+
def to_representation(self, obj, envelope='data'):
131+
"""
132+
Overriding to_representation allows using different serializers for the request and response.
133+
134+
This will allow scopes to be a serializer method field if an early version, or a relationship field for a later version
135+
"""
136+
context = self.context
137+
return ApiOAuth2PersonalTokenSerializer(instance=obj, context=context).data
138+
139+
140+
def expect_scopes_as_relationships(request):
141+
"""Whether serializer should expect scopes to be a relationship instead of an attribute
142+
143+
Scopes were previously an attribute on the serializer to mirror that they were a CharField on the model.
144+
Now that scopes are an m2m field with tokens, later versions of the serializer represent scopes as relationships.
145+
"""
146+
return StrictVersion(getattr(request, 'version', '2.0')) >= StrictVersion(SCOPES_RELATIONSHIP_VERSION)
147+
148+
def update_scopes(token, scopes):
149+
to_remove = token.scopes.difference(scopes)
150+
to_add = scopes.difference(token.scopes.all())
151+
for scope in to_remove:
152+
token.scopes.remove(scope)
153+
for scope in to_add:
154+
token.scopes.add(scope)
155+
return
156+
157+
def validate_requested_scopes(data):
158+
if not data:
159+
return []
160+
161+
if type(data) != list:
162+
data = data.split(' ')
163+
scopes = ApiOAuth2Scope.objects.filter(name__in=data)
164+
165+
if len(scopes) != len(data):
166+
raise exceptions.NotFound('Scope names must be one of: {}.'.format(
167+
', '.join(ApiOAuth2Scope.objects.values_list('name', flat=True)),
168+
))
169+
170+
if scopes.filter(is_public=False):
171+
raise exceptions.ValidationError('User requested invalid scope.')
172+
173+
return scopes

api/tokens/urls.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,5 @@
77
urlpatterns = [
88
url(r'^$', views.TokenList.as_view(), name='token-list'),
99
url(r'^(?P<_id>\w+)/$', views.TokenDetail.as_view(), name='token-detail'),
10+
url(r'^(?P<_id>\w+)/scopes/$', views.TokenScopesList.as_view(), name='token-scopes-list'),
1011
]

api/tokens/views.py

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,10 @@
1414
from api.base.filters import ListFilterMixin
1515
from api.base.utils import get_object_or_error
1616
from api.base.views import JSONAPIBaseView
17+
from api.base.parsers import JSONAPIMultipleRelationshipsParser, JSONAPIMultipleRelationshipsParserForRegularJSON
1718
from api.base import permissions as base_permissions
18-
from api.tokens.serializers import ApiOAuth2PersonalTokenSerializer
19+
from api.scopes.serializers import ScopeSerializer
20+
from api.tokens.serializers import ApiOAuth2PersonalTokenWritableSerializer
1921

2022
from osf.models import ApiOAuth2PersonalToken
2123

@@ -33,13 +35,14 @@ class TokenList(JSONAPIBaseView, generics.ListCreateAPIView, ListFilterMixin):
3335
required_read_scopes = [CoreScopes.TOKENS_READ]
3436
required_write_scopes = [CoreScopes.TOKENS_WRITE]
3537

36-
serializer_class = ApiOAuth2PersonalTokenSerializer
38+
serializer_class = ApiOAuth2PersonalTokenWritableSerializer
3739
view_category = 'tokens'
3840
view_name = 'token-list'
3941

4042
renderer_classes = [JSONRendererWithESISupport, JSONAPIRenderer, ] # Hide from web-browsable API tool
4143

4244
ordering = ('-id',)
45+
parser_classes = (JSONAPIMultipleRelationshipsParser, JSONAPIMultipleRelationshipsParserForRegularJSON,)
4346

4447
def get_default_queryset(self):
4548
return ApiOAuth2PersonalToken.objects.filter(owner=self.request.user, is_active=True)
@@ -69,11 +72,12 @@ class TokenDetail(JSONAPIBaseView, generics.RetrieveUpdateDestroyAPIView):
6972
required_read_scopes = [CoreScopes.TOKENS_READ]
7073
required_write_scopes = [CoreScopes.TOKENS_WRITE]
7174

72-
serializer_class = ApiOAuth2PersonalTokenSerializer
75+
serializer_class = ApiOAuth2PersonalTokenWritableSerializer
7376
view_category = 'tokens'
7477
view_name = 'token-detail'
7578

7679
renderer_classes = [JSONRendererWithESISupport, JSONAPIRenderer, ] # Hide from web-browsable API tool
80+
parser_classes = (JSONAPIMultipleRelationshipsParser, JSONAPIMultipleRelationshipsParserForRegularJSON,)
7781

7882
# overrides RetrieveAPIView
7983
def get_object(self):
@@ -97,3 +101,37 @@ def perform_update(self, serializer):
97101
"""Necessary to prevent owner field from being blanked on updates"""
98102
serializer.validated_data['owner'] = self.request.user
99103
serializer.save(owner=self.request.user)
104+
105+
106+
class TokenScopesList(JSONAPIBaseView, generics.ListAPIView):
107+
"""
108+
Get information about the scopes associated with a personal access token
109+
110+
Should not return information if the token belongs to a different user
111+
"""
112+
permission_classes = (
113+
drf_permissions.IsAuthenticated,
114+
base_permissions.OwnerOnly,
115+
base_permissions.TokenHasScope,
116+
)
117+
118+
required_read_scopes = [CoreScopes.TOKENS_READ]
119+
required_write_scopes = [CoreScopes.TOKENS_WRITE]
120+
121+
serializer_class = ScopeSerializer
122+
view_category = 'tokens'
123+
view_name = 'token-scopes-list'
124+
125+
renderer_classes = [JSONRendererWithESISupport, JSONAPIRenderer, ] # Hide from web-browsable API tool
126+
127+
def get_default_queryset(self):
128+
try:
129+
obj = get_object_or_error(ApiOAuth2PersonalToken, Q(_id=self.kwargs['_id'], is_active=True), self.request)
130+
except ApiOAuth2PersonalToken.DoesNotExist:
131+
raise NotFound
132+
self.check_object_permissions(self.request, obj)
133+
return obj.scopes.all()
134+
135+
# overrides ListAPIView
136+
def get_queryset(self):
137+
return self.get_default_queryset()

0 commit comments

Comments
 (0)