diff --git a/readthedocs/api/v3/mixins.py b/readthedocs/api/v3/mixins.py index 21c152778fa..1f7721e84c1 100644 --- a/readthedocs/api/v3/mixins.py +++ b/readthedocs/api/v3/mixins.py @@ -3,6 +3,7 @@ from rest_framework.response import Response from readthedocs.builds.models import Version +from readthedocs.organizations.models import Organization from readthedocs.projects.models import Project @@ -22,6 +23,11 @@ class NestedParentObjectMixin: 'version__slug', ] + ORGANIZATION_LOOKUP_NAMES = [ + 'organization__slug', + 'organizations__slug', + ] + def _get_parent_object_lookup(self, lookup_names): query_dict = self.get_parents_query_dict() for lookup in lookup_names: @@ -48,6 +54,19 @@ def _get_parent_version(self): project__slug=project_slug, ) + def _get_parent_organization(self): + slug = self._get_parent_object_lookup(self.ORGANIZATION_LOOKUP_NAMES) + + # when hitting ``/organizations//`` we don't have a "parent" organization + # because this endpoint is the base one, so we just get the organization from + # ``organization_slug`` kwargs + slug = slug or self.kwargs.get('organization_slug') + + return get_object_or_404( + Organization, + slug=slug, + ) + class ProjectQuerySetMixin(NestedParentObjectMixin): @@ -103,6 +122,57 @@ def get_queryset(self): return self.listing_objects(queryset, self.request.user) +class OrganizationQuerySetMixin(NestedParentObjectMixin): + + """ + Mixin to define queryset permissions for ViewSet only in one place. + + All APIv3 organizations' ViewSet should inherit this mixin, unless specific permissions + required. In that case, a specific mixin for that case should be defined. + """ + + def detail_objects(self, queryset, user): + # Filter results by user + return queryset.for_user(user=user) + + def listing_objects(self, queryset, user): + organization = self._get_parent_organization() + if self.has_admin_permission(user, organization): + return queryset + + return queryset.none() + + def has_admin_permission(self, user, organization): + if self.admin_organizations(user).filter(pk=organization.pk).exists(): + return True + + return False + + def admin_organizations(self, user): + return Organization.objects.for_admin_user(user=user) + + def get_queryset(self): + """ + Filter results based on user permissions. + + 1. returns ``Organizations`` where the user is admin if ``/organizations/`` is hit + 2. filters by parent ``organization_slug`` (NestedViewSetMixin) + 2. returns ``detail_objects`` results if it's a detail view + 3. returns ``listing_objects`` results if it's a listing view + 4. raise a ``NotFound`` exception otherwise + """ + + # We need to have defined the class attribute as ``queryset = Model.objects.all()`` + queryset = super().get_queryset() + + # Detail requests are public + if self.detail: + return self.detail_objects(queryset, self.request.user) + + # List view are only allowed if user is owner of parent project + return self.listing_objects(queryset, self.request.user) + + class UpdateMixin: """Make PUT to return 204 on success like PATCH does.""" diff --git a/readthedocs/api/v3/permissions.py b/readthedocs/api/v3/permissions.py index dbe090e75c3..4eabebcdda8 100644 --- a/readthedocs/api/v3/permissions.py +++ b/readthedocs/api/v3/permissions.py @@ -1,5 +1,21 @@ from rest_framework.permissions import IsAuthenticated, BasePermission +from readthedocs.core.utils.extend import SettingsOverrideObject + + +class UserProjectsListing(BasePermission): + + """Allow access to ``/projects`` (user's projects listing).""" + + def has_permission(self, request, view): + if view.basename == 'projects' and view.action in ( + 'list', + 'create', # used to create Form in BrowsableAPIRenderer + None, # needed for BrowsableAPIRenderer + ): + # hitting ``/projects/``, allowing + return True + class PublicDetailPrivateListing(IsAuthenticated): @@ -8,33 +24,22 @@ class PublicDetailPrivateListing(IsAuthenticated): * Always give permission for a ``detail`` request * Only give permission for ``listing`` request if user is admin of the project - * 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 == 'create', # used to create Form in BrowsableAPIRenderer - view.action is None, # needed for BrowsableAPIRenderer - ]): - # hitting ``/projects/``, allowing - return True - - # NOTE: ``superproject`` is an action name, defined by the class - # method under ``ProjectViewSet``. We should apply the same - # permissions restrictions than for a detail action (since it only - # returns one superproject if exists). ``list`` and ``retrieve`` are - # DRF standard action names (same as ``update`` or ``partial_update``). - if view.detail and view.action in ('list', 'retrieve', 'superproject'): - # detail view is only allowed on list/retrieve actions (not - # ``update`` or ``partial_update``). - return True - - project = view._get_parent_project() - if view.has_admin_permission(request.user, project): - return True + # NOTE: ``superproject`` is an action name, defined by the class + # method under ``ProjectViewSet``. We should apply the same + # permissions restrictions than for a detail action (since it only + # returns one superproject if exists). ``list`` and ``retrieve`` are + # DRF standard action names (same as ``update`` or ``partial_update``). + if view.detail and view.action in ('list', 'retrieve', 'superproject'): + # detail view is only allowed on list/retrieve actions (not + # ``update`` or ``partial_update``). + return True + + project = view._get_parent_project() + if view.has_admin_permission(request.user, project): + return True return False @@ -47,3 +52,46 @@ def has_permission(self, request, view): project = view._get_parent_project() if view.has_admin_permission(request.user, project): return True + + +class IsOrganizationAdmin(BasePermission): + + def has_permission(self, request, view): + organization = view._get_parent_organization() + if view.has_admin_permission(request.user, organization): + return True + + +class UserOrganizationsListing(BasePermission): + + def has_permission(self, request, view): + if view.basename == 'organizations' and view.action in ( + 'list', + None, # needed for BrowsableAPIRenderer + ): + # hitting ``/organizations/``, allowing + return True + + +class CommonPermissionsBase(BasePermission): + + """ + Common permission class used for most APIv3 endpoints. + + This class should be used by ``APIv3Settings.permission_classes`` to define + the permissions for most APIv3 endpoints. It has to be overriden from + corporate to define proper permissions there. + """ + + def has_permission(self, request, view): + if not IsAuthenticated().has_permission(request, view): + return False + + return ( + UserProjectsListing().has_permission(request, view) or + PublicDetailPrivateListing().has_permission(request, view) + ) + + +class CommonPermissions(SettingsOverrideObject): + _default_class = CommonPermissionsBase diff --git a/readthedocs/api/v3/serializers.py b/readthedocs/api/v3/serializers.py index 9a4daa72869..6d57949b69c 100644 --- a/readthedocs/api/v3/serializers.py +++ b/readthedocs/api/v3/serializers.py @@ -10,14 +10,14 @@ from rest_flex_fields.serializers import FlexFieldsSerializerMixin from rest_framework import serializers +from readthedocs.core.utils.extend import SettingsOverrideObject from readthedocs.builds.models import Build, Version from readthedocs.core.utils import slugify +from readthedocs.organizations.models import Organization, Team from readthedocs.projects.constants import ( LANGUAGES, PROGRAMMING_LANGUAGES, REPO_CHOICES, - PRIVACY_CHOICES, - PROTECTED, ) from readthedocs.projects.models import Project, EnvironmentVariable, ProjectRelationship from readthedocs.redirects.models import Redirect, TYPE_CHOICES as REDIRECT_TYPE_CHOICES @@ -433,7 +433,7 @@ def get_translations(self, obj): return self._absolute_url(path) -class ProjectCreateSerializer(FlexFieldsModelSerializer): +class ProjectCreateSerializerBase(FlexFieldsModelSerializer): """Serializer used to Import a Project.""" @@ -459,7 +459,11 @@ def validate_name(self, value): return value -class ProjectUpdateSerializer(FlexFieldsModelSerializer): +class ProjectCreateSerializer(SettingsOverrideObject): + _default_class = ProjectCreateSerializerBase + + +class ProjectUpdateSerializerBase(FlexFieldsModelSerializer): """Serializer used to modify a Project once imported.""" @@ -492,7 +496,11 @@ class Meta: ) -class ProjectSerializer(FlexFieldsModelSerializer): +class ProjectUpdateSerializer(SettingsOverrideObject): + _default_class = ProjectUpdateSerializerBase + + +class ProjectSerializerBase(FlexFieldsModelSerializer): homepage = serializers.SerializerMethodField() language = LanguageSerializer() @@ -567,6 +575,10 @@ def get_subproject_of(self, obj): return None +class ProjectSerializer(SettingsOverrideObject): + _default_class = ProjectSerializerBase + + class SubprojectCreateSerializer(FlexFieldsModelSerializer): """Serializer used to define a Project as subproject of another Project.""" @@ -800,3 +812,77 @@ class Meta: 'project', '_links', ] + + +class OrganizationLinksSerializer(BaseLinksSerializer): + _self = serializers.SerializerMethodField() + projects = serializers.SerializerMethodField() + + def get__self(self, obj): + path = reverse( + 'organizations-detail', + kwargs={ + 'organization_slug': obj.slug, + }) + return self._absolute_url(path) + + def get_projects(self, obj): + path = reverse( + 'organizations-projects-list', + kwargs={ + 'parent_lookup_organizations__slug': obj.slug, + }, + ) + return self._absolute_url(path) + + +class TeamSerializer(FlexFieldsModelSerializer): + + # TODO: add ``projects`` as flex field when we have a + # /organizations//teams//projects endpoint + + created = serializers.DateTimeField(source='pub_date') + modified = serializers.DateTimeField(source='modified_date') + + class Meta: + model = Team + fields = ( + 'name', + 'slug', + 'created', + 'modified', + 'access', + ) + + expandable_fields = { + 'members': (UserSerializer, {'many': True}), + } + + +class OrganizationSerializer(FlexFieldsModelSerializer): + + created = serializers.DateTimeField(source='pub_date') + modified = serializers.DateTimeField(source='modified_date') + owners = UserSerializer(many=True) + + _links = OrganizationLinksSerializer(source='*') + + class Meta: + model = Organization + fields = ( + 'name', + 'description', + 'url', + 'slug', + 'email', + 'owners', + 'created', + 'modified', + 'disabled', + '_links', + ) + + expandable_fields = { + 'projects': (ProjectSerializer, {'many': True}), + 'teams': (TeamSerializer, {'many': True}), + } diff --git a/readthedocs/api/v3/tests/mixins.py b/readthedocs/api/v3/tests/mixins.py index 9e3ac7ff06e..561b8e84ca4 100644 --- a/readthedocs/api/v3/tests/mixins.py +++ b/readthedocs/api/v3/tests/mixins.py @@ -153,8 +153,9 @@ def _create_subproject(self): ) self.project_relationship = self.project.add_subproject(self.subproject) - def _get_response_dict(self, view_name): - filename = Path(__file__).absolute().parent / 'responses' / f'{view_name}.json' + def _get_response_dict(self, view_name, filepath=None): + filepath = filepath or __file__ + filename = Path(filepath).absolute().parent / 'responses' / f'{view_name}.json' return json.load(open(filename)) def assertDictEqual(self, d1, d2): diff --git a/readthedocs/api/v3/tests/responses/projects-subprojects-detail.json b/readthedocs/api/v3/tests/responses/projects-subprojects-detail.json index eddba03136d..a327b50df26 100644 --- a/readthedocs/api/v3/tests/responses/projects-subprojects-detail.json +++ b/readthedocs/api/v3/tests/responses/projects-subprojects-detail.json @@ -35,6 +35,55 @@ "url": "https://github.com/rtfd/subproject" }, "slug": "subproject", + "subproject_of": { + "_links": { + "_self": "https://readthedocs.org/api/v3/projects/project/", + "builds": "https://readthedocs.org/api/v3/projects/project/builds/", + "environmentvariables": "https://readthedocs.org/api/v3/projects/project/environmentvariables/", + "redirects": "https://readthedocs.org/api/v3/projects/project/redirects/", + "subprojects": "https://readthedocs.org/api/v3/projects/project/subprojects/", + "superproject": "https://readthedocs.org/api/v3/projects/project/superproject/", + "translations": "https://readthedocs.org/api/v3/projects/project/translations/", + "versions": "https://readthedocs.org/api/v3/projects/project/versions/" + }, + "created": "2019-04-29T10:00:00Z", + "default_branch": "master", + "default_version": "latest", + "homepage": "http://project.com", + "id": 1, + "language": { + "code": "en", + "name": "English" + }, + "modified": "2019-04-29T12:00:00Z", + "name": "project", + "programming_language": { + "code": "words", + "name": "Only Words" + }, + "repository": { + "type": "git", + "url": "https://github.com/rtfd/project" + }, "slug": "project", + "subproject_of": null, + "tags": [ + "tag", + "project", + "test" + ], + "translation_of": null, + "urls": { + "builds": "https://readthedocs.org/projects/project/builds/", + "documentation": "http://project.readthedocs.io/en/latest/", + "home": "https://readthedocs.org/projects/project/", + "versions": "https://readthedocs.org/projects/project/versions/" + }, + "users": [ + { + "username": "testuser" + } + ] + }, "tags": [], "translation_of": null, "urls": { diff --git a/readthedocs/api/v3/tests/responses/projects-subprojects-list.json b/readthedocs/api/v3/tests/responses/projects-subprojects-list.json index 5231a70afc2..28d428f0220 100644 --- a/readthedocs/api/v3/tests/responses/projects-subprojects-list.json +++ b/readthedocs/api/v3/tests/responses/projects-subprojects-list.json @@ -40,6 +40,55 @@ "url": "https://github.com/rtfd/subproject" }, "slug": "subproject", + "subproject_of": { + "_links": { + "_self": "https://readthedocs.org/api/v3/projects/project/", + "builds": "https://readthedocs.org/api/v3/projects/project/builds/", + "environmentvariables": "https://readthedocs.org/api/v3/projects/project/environmentvariables/", + "redirects": "https://readthedocs.org/api/v3/projects/project/redirects/", + "subprojects": "https://readthedocs.org/api/v3/projects/project/subprojects/", + "superproject": "https://readthedocs.org/api/v3/projects/project/superproject/", + "translations": "https://readthedocs.org/api/v3/projects/project/translations/", + "versions": "https://readthedocs.org/api/v3/projects/project/versions/" + }, + "created": "2019-04-29T10:00:00Z", + "default_branch": "master", + "default_version": "latest", + "homepage": "http://project.com", + "id": 1, + "language": { + "code": "en", + "name": "English" + }, + "modified": "2019-04-29T12:00:00Z", + "name": "project", + "programming_language": { + "code": "words", + "name": "Only Words" + }, + "repository": { + "type": "git", + "url": "https://github.com/rtfd/project" + }, "slug": "project", + "subproject_of": null, + "tags": [ + "tag", + "project", + "test" + ], + "translation_of": null, + "urls": { + "builds": "https://readthedocs.org/projects/project/builds/", + "documentation": "http://project.readthedocs.io/en/latest/", + "home": "https://readthedocs.org/projects/project/", + "versions": "https://readthedocs.org/projects/project/versions/" + }, + "users": [ + { + "username": "testuser" + } + ] + }, "tags": [], "translation_of": null, "urls": { diff --git a/readthedocs/api/v3/tests/responses/projects-subprojects-list_POST.json b/readthedocs/api/v3/tests/responses/projects-subprojects-list_POST.json index 2bd991e119c..e6c4c2f090a 100644 --- a/readthedocs/api/v3/tests/responses/projects-subprojects-list_POST.json +++ b/readthedocs/api/v3/tests/responses/projects-subprojects-list_POST.json @@ -35,6 +35,55 @@ "url": "https://github.com/rtfd/project" }, "slug": "new-project", + "subproject_of": { + "_links": { + "_self": "https://readthedocs.org/api/v3/projects/project/", + "builds": "https://readthedocs.org/api/v3/projects/project/builds/", + "environmentvariables": "https://readthedocs.org/api/v3/projects/project/environmentvariables/", + "redirects": "https://readthedocs.org/api/v3/projects/project/redirects/", + "subprojects": "https://readthedocs.org/api/v3/projects/project/subprojects/", + "superproject": "https://readthedocs.org/api/v3/projects/project/superproject/", + "translations": "https://readthedocs.org/api/v3/projects/project/translations/", + "versions": "https://readthedocs.org/api/v3/projects/project/versions/" + }, + "created": "2019-04-29T10:00:00Z", + "default_branch": "master", + "default_version": "latest", + "homepage": "http://project.com", + "id": 1, + "language": { + "code": "en", + "name": "English" + }, + "modified": "2019-04-29T12:00:00Z", + "name": "project", + "programming_language": { + "code": "words", + "name": "Only Words" + }, + "repository": { + "type": "git", + "url": "https://github.com/rtfd/project" + }, "slug": "project", + "subproject_of": null, + "tags": [ + "tag", + "project", + "test" + ], + "translation_of": null, + "urls": { + "builds": "https://readthedocs.org/projects/project/builds/", + "documentation": "http://project.readthedocs.io/en/latest/", + "home": "https://readthedocs.org/projects/project/", + "versions": "https://readthedocs.org/projects/project/versions/" + }, + "users": [ + { + "username": "testuser" + } + ] + }, "tags": [], "translation_of": null, "urls": { diff --git a/readthedocs/api/v3/views.py b/readthedocs/api/v3/views.py index e60f16a9355..54d50b83930 100644 --- a/readthedocs/api/v3/views.py +++ b/readthedocs/api/v3/views.py @@ -21,19 +21,27 @@ from readthedocs.builds.models import Build, Version from readthedocs.core.utils import trigger_build +from readthedocs.core.utils.extend import SettingsOverrideObject +from readthedocs.organizations.models import Organization from readthedocs.projects.models import Project, EnvironmentVariable, ProjectRelationship from readthedocs.projects.views.mixins import ProjectImportMixin from readthedocs.redirects.models import Redirect from .filters import BuildFilter, ProjectFilter, VersionFilter -from .mixins import ProjectQuerySetMixin, UpdateMixin -from .permissions import PublicDetailPrivateListing, IsProjectAdmin +from .mixins import OrganizationQuerySetMixin, ProjectQuerySetMixin, UpdateMixin +from .permissions import ( + CommonPermissions, + IsProjectAdmin, + IsOrganizationAdmin, + UserOrganizationsListing, +) from .renderers import AlphabeticalSortedJSONRenderer from .serializers import ( BuildCreateSerializer, BuildSerializer, EnvironmentVariableSerializer, + OrganizationSerializer, ProjectSerializer, ProjectCreateSerializer, ProjectUpdateSerializer, @@ -62,7 +70,7 @@ class APIv3Settings: """ authentication_classes = (TokenAuthentication, SessionAuthentication) - permission_classes = (PublicDetailPrivateListing,) + permission_classes = (CommonPermissions,) pagination_class = LimitOffsetPagination LimitOffsetPagination.default_limit = 10 @@ -73,10 +81,10 @@ class APIv3Settings: metadata_class = SimpleMetadata -class ProjectsViewSet(APIv3Settings, NestedViewSetMixin, ProjectQuerySetMixin, - FlexFieldsMixin, ProjectImportMixin, CreateModelMixin, - UpdateMixin, UpdateModelMixin, - ReadOnlyModelViewSet): +class ProjectsViewSetBase(APIv3Settings, NestedViewSetMixin, ProjectQuerySetMixin, + FlexFieldsMixin, ProjectImportMixin, CreateModelMixin, + UpdateMixin, UpdateModelMixin, + ReadOnlyModelViewSet): model = Project lookup_field = 'slug' @@ -89,6 +97,10 @@ class ProjectsViewSet(APIv3Settings, NestedViewSetMixin, ProjectQuerySetMixin, 'active_versions.last_build.config', ] + def get_view_name(self): + # Avoid "Base" in BrowseableAPI view's title + return f'Projects {self.suffix}' + def get_serializer_class(self): """ Return correct serializer depending on the action. @@ -163,6 +175,10 @@ def superproject(self, request, project_slug): return Response(status=status.HTTP_404_NOT_FOUND) +class ProjectsViewSet(SettingsOverrideObject): + _default_class = ProjectsViewSetBase + + class SubprojectRelationshipViewSet(APIv3Settings, NestedViewSetMixin, ProjectQuerySetMixin, FlexFieldsMixin, CreateModelMixin, DestroyModelMixin, @@ -343,3 +359,58 @@ def perform_create(self, serializer): 'project': self._get_parent_project(), }) serializer.save() + + +class OrganizationsViewSetBase(APIv3Settings, NestedViewSetMixin, + OrganizationQuerySetMixin, + ReadOnlyModelViewSet): + + model = Organization + lookup_field = 'slug' + lookup_url_kwarg = 'organization_slug' + queryset = Organization.objects.all() + serializer_class = OrganizationSerializer + + permit_list_expands = [ + 'projects', + 'teams', + 'teams.members', + ] + + def get_view_name(self): + return f'Organizations {self.suffix}' + + def get_queryset(self): + # Allow hitting ``/api/v3/organizations/`` to list their own organizaions + if self.basename == 'organizations' and self.action == 'list': + # We force returning ``Organization`` objects here because it's + # under the ``organizations`` view. + return self.admin_organizations(self.request.user) + + return super().get_queryset() + + +class OrganizationsViewSet(SettingsOverrideObject): + _default_class = OrganizationsViewSetBase + + +class OrganizationsProjectsViewSetBase(APIv3Settings, NestedViewSetMixin, + OrganizationQuerySetMixin, + ReadOnlyModelViewSet): + + model = Project + lookup_field = 'slug' + lookup_url_kwarg = 'project_slug' + queryset = Project.objects.all() + serializer_class = ProjectSerializer + permit_list_expands = [ + 'organization', + 'organization.teams', + ] + + def get_view_name(self): + return f'Organizations Projects {self.suffix}' + + +class OrganizationsProjectsViewSet(SettingsOverrideObject): + _default_class = OrganizationsProjectsViewSetBase