Skip to content

Make APIv3 permission classes modular #5858

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 12 commits into from
Closed
1 change: 0 additions & 1 deletion readthedocs/api/v3/mixins.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
from django.shortcuts import get_object_or_404
from rest_framework.exceptions import NotFound

from readthedocs.builds.models import Version
from readthedocs.projects.models import Project
Expand Down
44 changes: 25 additions & 19 deletions readthedocs/api/v3/permissions.py
Original file line number Diff line number Diff line change
@@ -1,31 +1,37 @@
from rest_framework.permissions import IsAuthenticated
from rest_framework.permissions import BasePermission


class PublicDetailPrivateListing(IsAuthenticated):
class PublicDetailPrivateListing(BasePermission):

"""
Permission class for our custom use case.

* Always give permission for a ``detail`` request
* Only give permission for ``listing`` request if user is admin of the project
"""

def has_permission(self, request, view):
if view.detail:
return True

project = view._get_parent_project()
if view.has_admin_permission(request.user, project):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

has_admin_permission seems poorly named to me. It isn't immediately clear what it means. I realize it means that the user is a maintainer on the project but maybe it should be more explicit. How about is_project_maintainer or something like that. It does seem to mirror the method name on the ProjectQuerySet but it is much more clear what that method does in context (Project.objects.for_admin_user is fairly clear although could be better while view.has_admin_permission is not).

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is_project_maintainer is a good name, but I'm not sure it does fit with corporate (or under an organizational structure) --where only read access is granted.

Even if it doesn't fit 100%, it's still a better name 😄

return True


class ListCreateProject(BasePermission):

"""
Permission class to grant projects listing and project creation.

* Allow access to ``/projects`` (user's projects listing)
"""

def has_permission(self, request, view):
is_authenticated = super().has_permission(request, view)
if is_authenticated:
if view.basename == 'projects' and any([
view.action == 'list',
view.action is None, # needed for BrowsableAPIRenderer
]):
# hitting ``/projects/``, allowing
return True

if view.detail:
return True

project = view._get_parent_project()
if view.has_admin_permission(request.user, project):
return True

return False
if view.basename == 'projects' and any([
view.action == 'list',
view.action == 'create', # used to create Form in BrowsableAPIRenderer
view.action is None, # needed for BrowsableAPIRenderer
]):
# hitting ``/projects/``, allowing
return True
23 changes: 21 additions & 2 deletions readthedocs/api/v3/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
from rest_framework import serializers

from readthedocs.builds.models import Build, Version
from readthedocs.projects.constants import LANGUAGES, PROGRAMMING_LANGUAGES
from readthedocs.projects.constants import LANGUAGES, PROGRAMMING_LANGUAGES, REPO_CHOICES
from readthedocs.projects.models import Project


Expand Down Expand Up @@ -312,7 +312,10 @@ def get_project_homepage(self, obj):
class RepositorySerializer(serializers.Serializer):

url = serializers.CharField(source='repo')
type = serializers.CharField(source='repo_type')
type = serializers.ChoiceField(
source='repo_type',
choices=REPO_CHOICES,
)


class ProjectLinksSerializer(BaseLinksSerializer):
Expand Down Expand Up @@ -375,6 +378,22 @@ def get_translations(self, obj):
return self._absolute_url(path)


class ProjectCreateSerializer(FlexFieldsModelSerializer):

"""Serializer used to Import a Project."""

repository = RepositorySerializer(source='*')

class Meta:
model = Project
fields = (
'name',
'language',
'repository',
'project_url', # project_homepage
)


class ProjectSerializer(FlexFieldsModelSerializer):

language = LanguageSerializer()
Expand Down
47 changes: 47 additions & 0 deletions readthedocs/api/v3/tests/responses/projects-list_POST.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
{
"_links": {
"_self": "https://readthedocs.org/api/v3/projects/test-project/",
"builds": "https://readthedocs.org/api/v3/projects/test-project/builds/",
"subprojects": "https://readthedocs.org/api/v3/projects/test-project/subprojects/",
"superproject": "https://readthedocs.org/api/v3/projects/test-project/superproject/",
"translations": "https://readthedocs.org/api/v3/projects/test-project/translations/",
"versions": "https://readthedocs.org/api/v3/projects/test-project/versions/"
},
"created": "2019-04-29T14:00:00Z",
"default_branch": "master",
"default_version": "latest",
"description": null,
"id": 4,
"language": {
"code": "en",
"name": "English"
},
"modified": "2019-04-29T14:00:00Z",
"name": "Test Project",
"privacy_level": {
"code": "public",
"name": "Public"
},
"programming_language": {
"code": "words",
"name": "Only Words"
},
"repository": {
"type": "git",
"url": "https://github.com/rtfd/template"
},
"slug": "test-project",
"subproject_of": null,
"tags": [],
"translation_of": null,
"urls": {
"documentation": "http://readthedocs.org/docs/test-project/en/latest/",
"project_homepage": null
},
"users": [
{
"created": "2019-04-29T10:00:00Z",
"username": "testuser"
}
]
}
54 changes: 54 additions & 0 deletions readthedocs/api/v3/tests/test_projects.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import datetime
import mock
import json
from pathlib import Path

Expand Down Expand Up @@ -372,3 +373,56 @@ def test_projects_versions_detail_unique(self):

)
self.assertEqual(response.status_code, 200)

@mock.patch('readthedocs.api.v3.views.trigger_initial_build')
@mock.patch('readthedocs.api.v3.views.project_import')
def test_import_project(self, project_import, trigger_initial_build):
data = {
'name': 'Test Project',
'repository': {
'url': 'https://github.com/rtfd/template',
'type': 'git',
},
}

self.client.credentials(HTTP_AUTHORIZATION=f'Token {self.token.key}')
response = self.client.post(reverse('projects-list'), data)
self.assertEqual(response.status_code, 201)

query = Project.objects.filter(slug='test-project')
self.assertTrue(query.exists())

project = query.first()
self.assertEqual(project.name, 'Test Project')
self.assertEqual(project.slug, 'test-project')
self.assertEqual(project.repo, 'https://github.com/rtfd/template')
self.assertEqual(project.language, 'en')
self.assertIn(self.me, project.users.all())

# Signal sent
project_import.send.assert_has_calls(
[
mock.call(
sender=project,
request=mock.ANY,
),
],
)

# Build triggered
trigger_initial_build.assert_has_calls(
[
mock.call(
project,
self.me,
),
],
)

response_json = response.json()
response_json['created'] = '2019-04-29T14:00:00Z'
response_json['modified'] = '2019-04-29T14:00:00Z'
self.assertDictEqual(
response_json,
self._get_response_dict('projects-list_POST'),
)
67 changes: 56 additions & 11 deletions readthedocs/api/v3/views.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import django_filters.rest_framework as filters
from django.utils.safestring import mark_safe
from rest_flex_fields.views import FlexFieldsMixin
from rest_framework import status
from rest_framework.authentication import TokenAuthentication
from rest_framework.decorators import action
from rest_framework.metadata import SimpleMetadata
Expand All @@ -10,24 +11,28 @@
UpdateModelMixin,
)
from rest_framework.pagination import LimitOffsetPagination
from rest_framework.permissions import IsAuthenticated
from rest_framework.renderers import BrowsableAPIRenderer
from rest_framework.response import Response
from rest_framework.throttling import AnonRateThrottle, UserRateThrottle
from rest_framework.viewsets import GenericViewSet, ReadOnlyModelViewSet
from rest_framework_extensions.mixins import NestedViewSetMixin

from readthedocs.builds.models import Build, Version
from readthedocs.core.utils import trigger_build
from readthedocs.core.utils import trigger_build, trigger_initial_build
from readthedocs.projects.models import Project
from readthedocs.projects.signals import project_import


from .filters import BuildFilter, ProjectFilter, VersionFilter
from .mixins import ProjectQuerySetMixin
from .permissions import PublicDetailPrivateListing
from .permissions import PublicDetailPrivateListing, ListCreateProject
from .renderers import AlphabeticalSortedJSONRenderer
from .serializers import (
BuildCreateSerializer,
BuildSerializer,
ProjectSerializer,
ProjectCreateSerializer,
VersionSerializer,
VersionUpdateSerializer,
)
Expand All @@ -50,7 +55,7 @@ class APIv3Settings:
# Using only ``TokenAuthentication`` for now, so we can give access to
# specific carefully selected users only
authentication_classes = (TokenAuthentication,)
permission_classes = (PublicDetailPrivateListing,)
permission_classes = (IsAuthenticated & (ListCreateProject | PublicDetailPrivateListing),)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is starting to make me nervous and I don't really understand how ListCreateProject is that different from PublicDetailPrivateListing.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ouch! I thought that splitting these permissions in different classes would be easier to understand them.

Before this PR, everything was under a black box called PublicDetailPrivateListing. I decided to split it because it did more than what it name says.

My idea is:

  • IsAuthenticated is the first required condition,

then, the user has access to

  • ListCreateProject: list OR create at the /projects/ endpoint (which is kind special)

NOTE: ListCreateProject may be moved to ProjectsViewSet since it's the only place where it's needed

OR

  • PublicDetailPrivateListing: detail on any object or list of "private" (personal) objects.

Let me know if this helps to clarify or if you have another idea how to make it better.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think overall, I'd feel a lot better if these special cases were not on the base class that every API endpoint extends from. The project endpoint is special (although with all the extra complexity, I'm starting to doubt that it should be) and these changes should be localized to there!


pagination_class = LimitOffsetPagination
LimitOffsetPagination.default_limit = 10
Expand All @@ -62,7 +67,7 @@ class APIv3Settings:


class ProjectsViewSet(APIv3Settings, NestedViewSetMixin, ProjectQuerySetMixin,
FlexFieldsMixin, ReadOnlyModelViewSet):
FlexFieldsMixin, CreateModelMixin, ReadOnlyModelViewSet):

# Markdown docstring is automatically rendered by BrowsableAPIRenderer.

Expand Down Expand Up @@ -110,14 +115,13 @@ class ProjectsViewSet(APIv3Settings, NestedViewSetMixin, ProjectQuerySetMixin,
* Subprojects of a project: ``/api/v3/projects/{project_slug}/subprojects/``
* Superproject of a project: ``/api/v3/projects/{project_slug}/superproject/``

Go to [https://docs.readthedocs.io/en/stable/api/v3.html](https://docs.readthedocs.io/en/stable/api/v3.html)
Go to [https://docs.readthedocs.io/page/api/v3.html](https://docs.readthedocs.io/page/api/v3.html)
for a complete documentation of the APIv3.
""" # noqa

model = Project
lookup_field = 'slug'
lookup_url_kwarg = 'project_slug'
serializer_class = ProjectSerializer
filterset_class = ProjectFilter
queryset = Project.objects.all()
permit_list_expands = [
Expand All @@ -126,6 +130,20 @@ class ProjectsViewSet(APIv3Settings, NestedViewSetMixin, ProjectQuerySetMixin,
'active_versions.last_build.config',
]

def get_serializer_class(self):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see what you're doing here, but stylistically, I think it would be better to get rid of this method and make create() explicitly use a different serializer on incoming POST data.

In all cases, you want the JSON output to use ProjectSerializer. However, when there's user input like a user creating a project they aren't going to send every last field as some of them are automatic or have reasonable defaults so a different serializer may make sense. Since you're already overriding the output serializer in create(), I'd rather you just instead overrode the input serializer and used the default output serializer.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually, it looks like this is necessary due to how CreateModelMixin works.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I changed this. Although, since this PR is based on the "Import Project" one, some things changed there and I will re-check this once that one gets merged.

"""
Return correct serializer depending on the action.

For GET it returns a serializer with many fields and on PUT/PATCH/POST,
it return a serializer to validate just a few fields.
"""
if self.action in ('list', 'retrieve', 'superproject'):
return ProjectSerializer
elif self.action in ('create',):
return ProjectCreateSerializer
# elif self.action in ('update', 'partial_update'):
# return ProjectUpdateSerializer

def get_queryset(self):
# Allow hitting ``/api/v3/projects/`` to list their own projects
if self.basename == 'projects' and self.action == 'list':
Expand Down Expand Up @@ -161,6 +179,33 @@ def get_view_description(self, *args, **kwargs): # pylint: disable=arguments-di
return mark_safe(description.format(project_slug=project.slug))
return description

def create(self, request, *args, **kwargs):
"""
Override method to importing a Project.

* Save the Project object
* Assign the user from the request as owner
* Sent project_import signal
* Trigger an initial Build
"""
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
project = serializer.save()
headers = self.get_success_headers(serializer.data)

# TODO: these lines need to be adapted for Corporate
project.users.add(request.user)
project_import.send(sender=project, request=request)
trigger_initial_build(project, request.user)

# Full render Project
serializer = ProjectSerializer(instance=project)
return Response(
serializer.data,
status=status.HTTP_201_CREATED,
headers=headers,
)

@action(detail=True, methods=['get'])
def superproject(self, request, project_slug):
"""Return the superproject of a ``Project``."""
Expand All @@ -170,7 +215,7 @@ def superproject(self, request, project_slug):
data = self.get_serializer(superproject).data
return Response(data)
except Exception:
return Response(status=404)
return Response(status=status.HTTP_404_NOT_FOUND)


class SubprojectRelationshipViewSet(APIv3Settings, NestedViewSetMixin,
Expand Down Expand Up @@ -259,7 +304,7 @@ def update(self, request, *args, **kwargs):
# ``httpOnly`` on our cookies and the ``PUT/PATCH`` method are triggered
# via Javascript
super().update(request, *args, **kwargs)
return Response(status=204)
return Response(status=status.HTTP_204_NO_CONTENT)


class BuildsViewSet(APIv3Settings, NestedViewSetMixin, ProjectQuerySetMixin,
Expand Down Expand Up @@ -299,8 +344,8 @@ def create(self, request, **kwargs): # pylint: disable=arguments-differ

if build:
data.update({'triggered': True})
status = 202
code = status.HTTP_202_ACCEPTED
else:
data.update({'triggered': False})
status = 400
return Response(data=data, status=status)
code = status.HTTP_400_BAD_REQUEST
return Response(data=data, status=code)
Loading