Skip to content

Commit 1f9da42

Browse files
committed
Rely on a custom permission class
We need both things, 1. permission class to handle methods that does not pass over `get_queryset` (POST, for example) 2. a mixin with `get_queryset` to return the proper queryset for the logged in user
1 parent 73e4af2 commit 1f9da42

File tree

3 files changed

+83
-46
lines changed

3 files changed

+83
-46
lines changed

readthedocs/api/v3/mixins.py

+59-30
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,45 @@
11
from django.contrib.auth.models import User
22
from django.shortcuts import get_object_or_404
3-
from rest_framework.exceptions import PermissionDenied
3+
from rest_framework.exceptions import NotFound
44

5+
from readthedocs.builds.models import Version
56
from readthedocs.projects.models import Project
67

78

8-
class NestedParentProjectMixin:
9+
class NestedParentObjectMixin:
910

1011
# Lookup names defined on ``readthedocs/api/v3/urls.py`` when defining the
1112
# mapping between URLs and views through the router.
12-
LOOKUP_NAMES = [
13+
PROJECT_LOOKUP_NAMES = [
1314
'project__slug',
1415
'projects__slug',
1516
'superprojects__parent__slug',
1617
'main_language_project__slug',
1718
]
1819

19-
def _get_parent_project(self):
20+
VERSION_LOOKUP_NAMES = [
21+
'version__slug',
22+
]
23+
24+
def _get_parent_object(self, model, lookup_names):
2025
project_slug = None
2126
query_dict = self.get_parents_query_dict()
22-
for lookup in self.LOOKUP_NAMES:
27+
for lookup in lookup_names:
2328
value = query_dict.get(lookup)
2429
if value:
25-
project_slug = value
30+
slug = value
2631
break
2732

28-
return get_object_or_404(Project, slug=project_slug)
33+
return get_object_or_404(model, slug=slug)
34+
35+
def _get_parent_project(self):
36+
return self._get_parent_object(Project, self.PROJECT_LOOKUP_NAMES)
2937

38+
def _get_parent_version(self):
39+
return self._get_parent_object(Version, self.VERSION_LOOKUP_NAMES)
3040

31-
class APIAuthMixin(NestedParentProjectMixin):
41+
42+
class APIAuthMixin(NestedParentObjectMixin):
3243

3344
"""
3445
Mixin to define queryset permissions for ViewSet only in one place.
@@ -37,40 +48,58 @@ class APIAuthMixin(NestedParentProjectMixin):
3748
required. In that case, an specific mixin for that case should be defined.
3849
"""
3950

51+
def detail_objects(self, queryset, user):
52+
# Filter results by user
53+
# NOTE: we don't override the manager in User model, so we don't have
54+
# ``.api`` method there
55+
if self.model is not User:
56+
queryset = queryset.api(user=user)
57+
58+
return queryset
59+
60+
def listing_objects(self, queryset, user):
61+
project = self._get_parent_project()
62+
if self.has_admin_permission(user, project):
63+
return queryset
64+
65+
def has_admin_permission(self, user, project):
66+
if project in self.admin_projects(user):
67+
return True
68+
69+
return False
70+
71+
def admin_projects(self, user):
72+
return Project.objects.for_admin_user(user=user)
73+
4074
def get_queryset(self):
4175
"""
4276
Filter results based on user permissions.
4377
44-
1. filters by parent ``project_slug`` (NestedViewSetMixin).
45-
2. return those results if it's a detail view.
46-
3. if it's a list view, it checks if the user is admin of the parent
47-
object (project) and return the same results.
48-
4. raise a ``PermissionDenied`` exception if the user is not an admin.
78+
1. returns ``Projects`` where the user is admin if ``/projects/`` is hit
79+
2. filters by parent ``project_slug`` (NestedViewSetMixin)
80+
2. returns ``detail_objects`` results if it's a detail view
81+
3. returns ``listing_objects`` results if it's a listing view
82+
4. raise a ``NotFound`` exception otherwise
4983
"""
5084

85+
# Allow hitting ``/api/v3/projects/`` to list their own projects
86+
if self.basename == 'projects' and self.action == 'list':
87+
# We force returning ``Project`` objects here because it's under the
88+
# ``projects`` view. This could be moved to a specific
89+
# ``get_queryset`` in the view.
90+
return self.admin_projects(self.request.user)
91+
5192
# NOTE: ``super().get_queryset`` produces the filter by ``NestedViewSetMixin``
5293
# we need to have defined the class attribute as ``queryset = Model.objects.all()``
5394
queryset = super().get_queryset()
5495

55-
# Filter results by user
56-
# NOTE: we don't override the manager in User model, so we don't have
57-
# ``.api`` method there
58-
if self.model is not User:
59-
queryset = queryset.api(user=self.request.user)
60-
6196
# Detail requests are public
6297
if self.detail:
63-
return queryset
64-
65-
allowed_projects = Project.objects.for_admin_user(user=self.request.user)
66-
67-
# Allow hitting ``/api/v3/projects/`` to list their own projects
68-
if self.basename == 'projects' and self.action == 'list':
69-
return allowed_projects
98+
return self.detail_objects(queryset, self.request.user)
7099

71100
# List view are only allowed if user is owner of parent project
72-
project = self._get_parent_project()
73-
if project in allowed_projects:
74-
return queryset
101+
listing_objects = self.listing_objects(queryset, self.request.user)
102+
if listing_objects:
103+
return listing_objects
75104

76-
raise PermissionDenied
105+
raise NotFound

readthedocs/api/v3/permissions.py

+20
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
from rest_framework.permissions import IsAuthenticated
2+
3+
4+
class PublicDetailPrivateListing(IsAuthenticated):
5+
6+
def has_permission(self, request, view):
7+
is_authenticated = super().has_permission(request, view)
8+
if is_authenticated:
9+
if view.basename == 'projects' and view.action == 'list':
10+
# hitting ``/projects/``, allowing
11+
return True
12+
13+
if view.detail:
14+
return True
15+
16+
project = view._get_parent_project()
17+
if view.has_admin_permission(request.user, project):
18+
return True
19+
20+
return False

readthedocs/api/v3/views.py

+4-16
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828

2929
from .filters import BuildFilter, ProjectFilter, VersionFilter
3030
from .mixins import APIAuthMixin
31+
from .permissions import PublicDetailPrivateListing
3132
from .renderer import AlphabeticalSortedJSONRenderer
3233
from .serializers import (
3334
BuildSerializer,
@@ -56,7 +57,7 @@ class APIv3Settings:
5657
# Using only ``TokenAuthentication`` for now, so we can give access to
5758
# specific carefully selected users only
5859
authentication_classes = (TokenAuthentication,)
59-
permission_classes = (IsAuthenticated,)
60+
permission_classes = (PublicDetailPrivateListing,)
6061

6162
pagination_class = LimitOffsetPagination
6263
LimitOffsetPagination.default_limit = 10
@@ -255,21 +256,8 @@ def get_serializer_class(self):
255256
return BuildCreateSerializer
256257

257258
def create(self, request, **kwargs):
258-
parent_lookup_project__slug = kwargs.get('parent_lookup_project__slug')
259-
parent_lookup_version__slug = kwargs.get('parent_lookup_version__slug')
260-
261-
version = None
262-
project = get_object_or_404(
263-
Project,
264-
slug=parent_lookup_project__slug,
265-
users=request.user,
266-
)
267-
268-
if parent_lookup_version__slug:
269-
version = get_object_or_404(
270-
project.versions.all(),
271-
slug=parent_lookup_version__slug,
272-
)
259+
project = self._get_parent_project()
260+
version = self._get_parent_version()
273261

274262
_, build = trigger_build(project, version=version)
275263

0 commit comments

Comments
 (0)