Skip to content

Commit

Permalink
Merge branch 'release/19.23.0'
Browse files Browse the repository at this point in the history
  • Loading branch information
pattisdr committed Aug 19, 2019
2 parents 1b21f10 + 9119622 commit 6c81f5c
Show file tree
Hide file tree
Showing 22 changed files with 1,047 additions and 157 deletions.
6 changes: 6 additions & 0 deletions CHANGELOG
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@

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

19.23.0 (2019-08-19)
===================
- Represents scopes as an m2m field on personal access tokens instead of a CharField
- APIv2.17 treats scopes as relationships on tokens instead of attributes. Earlier
API versions still serialize scopes as attributes.

19.22.0 (2019-08-14)
===================
- APIv2: Editable registrations
Expand Down
1 change: 1 addition & 0 deletions api/base/settings/defaults.py
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,7 @@
'2.14',
'2.15',
'2.16',
'2.17',
),
'DEFAULT_FILTER_BACKENDS': ('api.base.filters.OSFOrderingFilter',),
'DEFAULT_PAGINATION_CLASS': 'api.base.pagination.JSONAPIPagination',
Expand Down
4 changes: 2 additions & 2 deletions api/scopes/permissions.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
from rest_framework import permissions
from api.scopes.serializers import Scope
from osf.models.oauth import ApiOAuth2Scope

class IsPublicScope(permissions.BasePermission):

def has_object_permission(self, request, view, obj):
assert isinstance(obj, Scope), 'obj must be an Scope got {}'.format(obj)
assert isinstance(obj, ApiOAuth2Scope), 'obj must be an ApiOAuth2Scope got {}'.format(obj)
return obj.is_public
17 changes: 4 additions & 13 deletions api/scopes/serializers.py
Original file line number Diff line number Diff line change
@@ -1,28 +1,19 @@
from rest_framework import serializers as ser
from website import settings
from urlparse import urljoin

from api.base.serializers import (
JSONAPISerializer,
LinksField,
)


class Scope(object):
def __init__(self, id, scope):
scope = scope or {}
self.id = id
self.description = scope.description
self.is_public = scope.is_public

def absolute_url(self):
return urljoin(settings.API_DOMAIN, '/v2/scopes/{}/'.format(self.id))
# With this API version, scopes are a M2M field on ApiOAuth2PersonalToken, and
# serialized as relationship.
SCOPES_RELATIONSHIP_VERSION = '2.17'

class ScopeSerializer(JSONAPISerializer):

filterable_fields = frozenset(['id'])

id = ser.CharField(read_only=True)
id = ser.CharField(read_only=True, source='name')
description = ser.CharField(read_only=True)
links = LinksField({'self': 'get_absolute_url'})

Expand Down
23 changes: 10 additions & 13 deletions api/scopes/views.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
from rest_framework import generics, permissions as drf_permissions
from rest_framework.exceptions import NotFound
from framework.auth.oauth_scopes import CoreScopes, public_scopes
from framework.auth.oauth_scopes import CoreScopes

from api.base.filters import ListFilterMixin
from api.base import permissions as base_permissions
from api.scopes.serializers import ScopeSerializer, Scope
from api.scopes.serializers import ScopeSerializer
from api.scopes.permissions import IsPublicScope
from api.base.views import JSONAPIBaseView
from api.base.pagination import MaxSizePagination
from osf.models.oauth import ApiOAuth2Scope


class ScopeDetail(JSONAPIBaseView, generics.RetrieveAPIView):
Expand All @@ -30,14 +31,14 @@ class ScopeDetail(JSONAPIBaseView, generics.RetrieveAPIView):
# overrides RetrieveAPIView
def get_object(self):
id = self.kwargs[self.lookup_url_kwarg]
scope_item = public_scopes.get(id, None)
if scope_item:
scope = Scope(id=id, scope=scope_item)
self.check_object_permissions(self.request, scope)
return scope
else:
try:
scope = ApiOAuth2Scope.objects.get(name=id)
except ApiOAuth2Scope.DoesNotExist:
raise NotFound

self.check_object_permissions(self.request, scope)
return scope


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

def get_default_queryset(self):
scopes = []
for key, value in public_scopes.items():
if value.is_public:
scopes.append(Scope(id=key, scope=value))
return scopes
return ApiOAuth2Scope.objects.filter(is_public=True)

def get_queryset(self):
return self.get_queryset_from_request()
122 changes: 101 additions & 21 deletions api/tokens/serializers.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,47 @@
from rest_framework import serializers as ser
from rest_framework import exceptions

from framework.auth.oauth_scopes import public_scopes
from osf.exceptions import ValidationError
from osf.models import ApiOAuth2PersonalToken
from osf.models import ApiOAuth2PersonalToken, ApiOAuth2Scope

from api.base.exceptions import format_validation_error
from api.base.serializers import JSONAPISerializer, LinksField, IDField, TypeField
from api.base.serializers import JSONAPISerializer, LinksField, IDField, TypeField, RelationshipField, StrictVersion
from api.scopes.serializers import SCOPES_RELATIONSHIP_VERSION


class TokenScopesRelationshipField(RelationshipField):

def to_internal_value(self, data):
return {'scopes': data}


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

def __init__(self, *args, **kwargs):
super(ApiOAuth2PersonalTokenSerializer, self).__init__(*args, **kwargs)

request = kwargs['context']['request']

# Dynamically adding scopes field here, depending on the version
if expect_scopes_as_relationships(request):
field = TokenScopesRelationshipField(
related_view='tokens:token-scopes-list',
related_view_kwargs={'_id': '<_id>'},
always_embed=True,
read_only=False,
)
self.fields['scopes'] = field
self.fields['owner'] = RelationshipField(
related_view='users:user-detail',
related_view_kwargs={'user_id': '<owner._id>'},
)
# Making scopes embeddable
self.context['embed']['scopes'] = self.context['view']._get_embed_partial('scopes', field)
else:
self.fields['scopes'] = ser.SerializerMethodField()
self.fields['owner'] = ser.SerializerMethodField()

id = IDField(source='_id', read_only=True, help_text='The object ID for this token (automatically generated)')
type = TypeField()

Expand All @@ -20,17 +50,6 @@ class ApiOAuth2PersonalTokenSerializer(JSONAPISerializer):
required=True,
)

owner = ser.CharField(
help_text='The user who owns this token',
read_only=True, # Don't let user register a token in someone else's name
source='owner._id',
)

scopes = ser.CharField(
help_text='Governs permissions associated with this token',
required=True,
)

token_id = ser.CharField(read_only=True, allow_blank=True)

class Meta:
Expand All @@ -40,6 +59,12 @@ class Meta:
'html': 'absolute_url',
})

def get_owner(self, obj):
return obj.owner._id

def get_scopes(self, obj):
return ' '.join([scope.name for scope in obj.scopes.all()])

def absolute_url(self, obj):
return obj.absolute_url

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

def create(self, validated_data):
validate_requested_scopes(validated_data)
scopes = validate_requested_scopes(validated_data.pop('scopes', None))
if not scopes:
raise exceptions.ValidationError('Cannot create a token without scopes.')
instance = ApiOAuth2PersonalToken(**validated_data)
try:
instance.save()
except ValidationError as e:
detail = format_validation_error(e)
raise exceptions.ValidationError(detail=detail)
for scope in scopes:
instance.scopes.add(scope)
return instance

def update(self, instance, validated_data):
validate_requested_scopes(validated_data)
scopes = validate_requested_scopes(validated_data.pop('scopes', None))
assert isinstance(instance, ApiOAuth2PersonalToken), 'instance must be an ApiOAuth2PersonalToken'

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.
Expand All @@ -79,15 +108,66 @@ def update(self, instance, validated_data):
continue
else:
setattr(instance, attr, value)
if scopes:
update_scopes(instance, scopes)
try:
instance.save()
except ValidationError as e:
detail = format_validation_error(e)
raise exceptions.ValidationError(detail=detail)
return instance

def validate_requested_scopes(validated_data):
scopes_set = set(validated_data.get('scopes', '').split(' '))
for scope in scopes_set:
if scope not in public_scopes or not public_scopes[scope].is_public:
raise exceptions.ValidationError('User requested invalid scope')

class ApiOAuth2PersonalTokenWritableSerializer(ApiOAuth2PersonalTokenSerializer):
def __init__(self, *args, **kwargs):
super(ApiOAuth2PersonalTokenWritableSerializer, self).__init__(*args, **kwargs)
request = kwargs['context']['request']

# Dynamically overriding scopes field for early versions to make scopes writable via an attribute
if not expect_scopes_as_relationships(request):
self.fields['scopes'] = ser.CharField(write_only=True, required=False)

def to_representation(self, obj, envelope='data'):
"""
Overriding to_representation allows using different serializers for the request and response.
This will allow scopes to be a serializer method field if an early version, or a relationship field for a later version
"""
context = self.context
return ApiOAuth2PersonalTokenSerializer(instance=obj, context=context).data


def expect_scopes_as_relationships(request):
"""Whether serializer should expect scopes to be a relationship instead of an attribute
Scopes were previously an attribute on the serializer to mirror that they were a CharField on the model.
Now that scopes are an m2m field with tokens, later versions of the serializer represent scopes as relationships.
"""
return StrictVersion(getattr(request, 'version', '2.0')) >= StrictVersion(SCOPES_RELATIONSHIP_VERSION)

def update_scopes(token, scopes):
to_remove = token.scopes.difference(scopes)
to_add = scopes.difference(token.scopes.all())
for scope in to_remove:
token.scopes.remove(scope)
for scope in to_add:
token.scopes.add(scope)
return

def validate_requested_scopes(data):
if not data:
return []

if type(data) != list:
data = data.split(' ')
scopes = ApiOAuth2Scope.objects.filter(name__in=data)

if len(scopes) != len(data):
raise exceptions.NotFound('Scope names must be one of: {}.'.format(
', '.join(ApiOAuth2Scope.objects.values_list('name', flat=True)),
))

if scopes.filter(is_public=False):
raise exceptions.ValidationError('User requested invalid scope.')

return scopes
1 change: 1 addition & 0 deletions api/tokens/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,5 @@
urlpatterns = [
url(r'^$', views.TokenList.as_view(), name='token-list'),
url(r'^(?P<_id>\w+)/$', views.TokenDetail.as_view(), name='token-detail'),
url(r'^(?P<_id>\w+)/scopes/$', views.TokenScopesList.as_view(), name='token-scopes-list'),
]
44 changes: 41 additions & 3 deletions api/tokens/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,10 @@
from api.base.filters import ListFilterMixin
from api.base.utils import get_object_or_error
from api.base.views import JSONAPIBaseView
from api.base.parsers import JSONAPIMultipleRelationshipsParser, JSONAPIMultipleRelationshipsParserForRegularJSON
from api.base import permissions as base_permissions
from api.tokens.serializers import ApiOAuth2PersonalTokenSerializer
from api.scopes.serializers import ScopeSerializer
from api.tokens.serializers import ApiOAuth2PersonalTokenWritableSerializer

from osf.models import ApiOAuth2PersonalToken

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

serializer_class = ApiOAuth2PersonalTokenSerializer
serializer_class = ApiOAuth2PersonalTokenWritableSerializer
view_category = 'tokens'
view_name = 'token-list'

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

ordering = ('-id',)
parser_classes = (JSONAPIMultipleRelationshipsParser, JSONAPIMultipleRelationshipsParserForRegularJSON,)

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

serializer_class = ApiOAuth2PersonalTokenSerializer
serializer_class = ApiOAuth2PersonalTokenWritableSerializer
view_category = 'tokens'
view_name = 'token-detail'

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

# overrides RetrieveAPIView
def get_object(self):
Expand All @@ -97,3 +101,37 @@ def perform_update(self, serializer):
"""Necessary to prevent owner field from being blanked on updates"""
serializer.validated_data['owner'] = self.request.user
serializer.save(owner=self.request.user)


class TokenScopesList(JSONAPIBaseView, generics.ListAPIView):
"""
Get information about the scopes associated with a personal access token
Should not return information if the token belongs to a different user
"""
permission_classes = (
drf_permissions.IsAuthenticated,
base_permissions.OwnerOnly,
base_permissions.TokenHasScope,
)

required_read_scopes = [CoreScopes.TOKENS_READ]
required_write_scopes = [CoreScopes.TOKENS_WRITE]

serializer_class = ScopeSerializer
view_category = 'tokens'
view_name = 'token-scopes-list'

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

def get_default_queryset(self):
try:
obj = get_object_or_error(ApiOAuth2PersonalToken, Q(_id=self.kwargs['_id'], is_active=True), self.request)
except ApiOAuth2PersonalToken.DoesNotExist:
raise NotFound
self.check_object_permissions(self.request, obj)
return obj.scopes.all()

# overrides ListAPIView
def get_queryset(self):
return self.get_default_queryset()
Loading

0 comments on commit 6c81f5c

Please sign in to comment.