diff --git a/apps/api/plane/app/permissions/__init__.py b/apps/api/plane/app/permissions/__init__.py index b7a095e74..95ee038e1 100644 --- a/apps/api/plane/app/permissions/__init__.py +++ b/apps/api/plane/app/permissions/__init__.py @@ -13,3 +13,4 @@ from .project import ( ProjectLitePermission, ) from .base import allow_permission, ROLE +from .page import ProjectPagePermission diff --git a/apps/api/plane/app/permissions/page.py b/apps/api/plane/app/permissions/page.py new file mode 100644 index 000000000..96bf63527 --- /dev/null +++ b/apps/api/plane/app/permissions/page.py @@ -0,0 +1,125 @@ +from plane.db.models import ProjectMember, Page +from plane.app.permissions import ROLE + + +from rest_framework.permissions import BasePermission, SAFE_METHODS + + +# Permission Mappings for workspace members +ADMIN = ROLE.ADMIN.value +MEMBER = ROLE.MEMBER.value +GUEST = ROLE.GUEST.value + + +class ProjectPagePermission(BasePermission): + """ + Custom permission to control access to pages within a workspace + based on user roles, page visibility (public/private), and feature flags. + """ + + def has_permission(self, request, view): + """ + Check basic project-level permissions before checking object-level permissions. + """ + if request.user.is_anonymous: + return False + + user_id = request.user.id + slug = view.kwargs.get("slug") + page_id = view.kwargs.get("page_id") + project_id = view.kwargs.get("project_id") + + # Hook for extended validation + extended_access, role = self._check_access_and_get_role( + request, slug, project_id + ) + if extended_access is False: + return False + + if page_id: + page = Page.objects.get(id=page_id, workspace__slug=slug) + + # Allow access if the user is the owner of the page + if page.owned_by_id == user_id: + return True + + # Handle private page access + if page.access == Page.PRIVATE_ACCESS: + return self._has_private_page_action_access( + request, slug, page, project_id + ) + + # Handle public page access + return self._has_public_page_action_access(request, role) + + def _check_project_member_access(self, request, slug, project_id): + """ + Check if the user is a project member. + """ + return ( + ProjectMember.objects.filter( + member=request.user, + workspace__slug=slug, + is_active=True, + project_id=project_id, + ) + .values_list("role", flat=True) + .first() + ) + + def _check_access_and_get_role(self, request, slug, project_id): + """ + Hook for extended access checking + Returns: True (allow), False (deny), None (continue with normal flow) + """ + role = self._check_project_member_access(request, slug, project_id) + if not role: + return False, None + return True, role + + def _has_private_page_action_access(self, request, slug, page, project_id): + """ + Check access to private pages. Override for feature flag logic. + """ + # Base implementation: only owner can access private pages + return False + + def _check_project_action_access(self, request, role): + method = request.method + + # Only admins can create (POST) pages + if method == "POST": + if role in [ADMIN, MEMBER]: + return True + return False + + # Safe methods (GET, HEAD, OPTIONS) allowed for all active roles + if method in SAFE_METHODS: + if role in [ADMIN, MEMBER, GUEST]: + return True + return False + + # PUT/PATCH: Admins and members can update + if method in ["PUT", "PATCH"]: + if role in [ADMIN, MEMBER]: + return True + return False + + # DELETE: Only admins can delete + if method == "DELETE": + if role in [ADMIN]: + return True + return False + + # Deny by default + return False + + def _has_public_page_action_access(self, request, role): + """ + Check if the user has permission to access a public page + and can perform operations on the page. + """ + project_member_exists = self._check_project_action_access(request, role) + if not project_member_exists: + return False + return True diff --git a/apps/api/plane/app/serializers/__init__.py b/apps/api/plane/app/serializers/__init__.py index 0116b2061..18be363cd 100644 --- a/apps/api/plane/app/serializers/__init__.py +++ b/apps/api/plane/app/serializers/__init__.py @@ -92,8 +92,6 @@ from .importer import ImporterSerializer from .page import ( PageSerializer, - PageLogSerializer, - SubPageSerializer, PageDetailSerializer, PageVersionSerializer, PageBinaryUpdateSerializer, diff --git a/apps/api/plane/app/serializers/page.py b/apps/api/plane/app/serializers/page.py index 9ac6cc414..174a6ee48 100644 --- a/apps/api/plane/app/serializers/page.py +++ b/apps/api/plane/app/serializers/page.py @@ -130,32 +130,6 @@ class PageDetailSerializer(PageSerializer): fields = PageSerializer.Meta.fields + ["description_html"] -class SubPageSerializer(BaseSerializer): - entity_details = serializers.SerializerMethodField() - - class Meta: - model = PageLog - fields = "__all__" - read_only_fields = ["workspace", "page"] - - def get_entity_details(self, obj): - entity_name = obj.entity_name - if entity_name == "forward_link" or entity_name == "back_link": - try: - page = Page.objects.get(pk=obj.entity_identifier) - return PageSerializer(page).data - except Page.DoesNotExist: - return None - return None - - -class PageLogSerializer(BaseSerializer): - class Meta: - model = PageLog - fields = "__all__" - read_only_fields = ["workspace", "page"] - - class PageVersionSerializer(BaseSerializer): class Meta: model = PageVersion diff --git a/apps/api/plane/app/urls/page.py b/apps/api/plane/app/urls/page.py index f7eb7e424..e7665d566 100644 --- a/apps/api/plane/app/urls/page.py +++ b/apps/api/plane/app/urls/page.py @@ -4,14 +4,11 @@ from django.urls import path from plane.app.views import ( PageViewSet, PageFavoriteViewSet, - PageLogEndpoint, - SubPagesEndpoint, PagesDescriptionViewSet, PageVersionEndpoint, PageDuplicateEndpoint, ) - urlpatterns = [ path( "workspaces//projects//pages/", @@ -19,7 +16,7 @@ urlpatterns = [ name="project-pages", ), path( - "workspaces//projects//pages//", + "workspaces//projects//pages//", PageViewSet.as_view( {"get": "retrieve", "patch": "partial_update", "delete": "destroy"} ), @@ -27,45 +24,30 @@ urlpatterns = [ ), # favorite pages path( - "workspaces//projects//favorite-pages//", + "workspaces//projects//favorite-pages//", PageFavoriteViewSet.as_view({"post": "create", "delete": "destroy"}), name="user-favorite-pages", ), # archived pages path( - "workspaces//projects//pages//archive/", + "workspaces//projects//pages//archive/", PageViewSet.as_view({"post": "archive", "delete": "unarchive"}), name="project-page-archive-unarchive", ), # lock and unlock path( - "workspaces//projects//pages//lock/", + "workspaces//projects//pages//lock/", PageViewSet.as_view({"post": "lock", "delete": "unlock"}), name="project-pages-lock-unlock", ), # private and public page path( - "workspaces//projects//pages//access/", + "workspaces//projects//pages//access/", PageViewSet.as_view({"post": "access"}), name="project-pages-access", ), path( - "workspaces//projects//pages//transactions/", - PageLogEndpoint.as_view(), - name="page-transactions", - ), - path( - "workspaces//projects//pages//transactions//", - PageLogEndpoint.as_view(), - name="page-transactions", - ), - path( - "workspaces//projects//pages//sub-pages/", - SubPagesEndpoint.as_view(), - name="sub-page", - ), - path( - "workspaces//projects//pages//description/", + "workspaces//projects//pages//description/", PagesDescriptionViewSet.as_view({"get": "retrieve", "patch": "partial_update"}), name="page-description", ), diff --git a/apps/api/plane/app/views/__init__.py b/apps/api/plane/app/views/__init__.py index 6d56473e3..9d81754e2 100644 --- a/apps/api/plane/app/views/__init__.py +++ b/apps/api/plane/app/views/__init__.py @@ -165,8 +165,6 @@ from .api import ApiTokenEndpoint, ServiceApiTokenEndpoint from .page.base import ( PageViewSet, PageFavoriteViewSet, - PageLogEndpoint, - SubPagesEndpoint, PagesDescriptionViewSet, PageDuplicateEndpoint, ) diff --git a/apps/api/plane/app/views/page/base.py b/apps/api/plane/app/views/page/base.py index e4ee1890b..ebcfdd8f1 100644 --- a/apps/api/plane/app/views/page/base.py +++ b/apps/api/plane/app/views/page/base.py @@ -7,8 +7,6 @@ from django.core.serializers.json import DjangoJSONEncoder # Django imports from django.db import connection from django.db.models import Exists, OuterRef, Q, Value, UUIDField -from django.utils.decorators import method_decorator -from django.views.decorators.gzip import gzip_page from django.http import StreamingHttpResponse from django.contrib.postgres.aggregates import ArrayAgg from django.contrib.postgres.fields import ArrayField @@ -21,9 +19,7 @@ from rest_framework.response import Response # Module imports from plane.app.permissions import allow_permission, ROLE from plane.app.serializers import ( - PageLogSerializer, PageSerializer, - SubPageSerializer, PageDetailSerializer, PageBinaryUpdateSerializer, ) @@ -37,12 +33,14 @@ from plane.db.models import ( UserRecentVisit, ) from plane.utils.error_codes import ERROR_CODES + +# Local imports from ..base import BaseAPIView, BaseViewSet from plane.bgtasks.page_transaction_task import page_transaction from plane.bgtasks.page_version_task import page_version from plane.bgtasks.recent_visited_task import recent_visited_task from plane.bgtasks.copy_s3_object import copy_s3_objects_of_description_and_assets - +from plane.app.permissions import ProjectPagePermission def unarchive_archive_page_and_descendants(page_id, archived_at): # Your SQL query @@ -63,6 +61,7 @@ def unarchive_archive_page_and_descendants(page_id, archived_at): class PageViewSet(BaseViewSet): serializer_class = PageSerializer model = Page + permission_classes = [ProjectPagePermission] search_fields = ["name"] def get_queryset(self): @@ -117,7 +116,6 @@ class PageViewSet(BaseViewSet): .distinct() ) - @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) def create(self, request, slug, project_id): serializer = PageSerializer( data=request.data, @@ -139,11 +137,10 @@ class PageViewSet(BaseViewSet): return Response(serializer.data, status=status.HTTP_201_CREATED) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) - def partial_update(self, request, slug, project_id, pk): + def partial_update(self, request, slug, project_id, page_id): try: page = Page.objects.get( - pk=pk, workspace__slug=slug, projects__id=project_id + pk=page_id, workspace__slug=slug, projects__id=project_id ) if page.is_locked: @@ -181,22 +178,19 @@ class PageViewSet(BaseViewSet): {"description_html": page_description}, cls=DjangoJSONEncoder, ), - page_id=pk, + page_id=page_id, ) return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) except Page.DoesNotExist: return Response( - { - "error": "Access cannot be updated since this page is owned by someone else" - }, + {"error": "Access cannot be updated since this page is owned by someone else"}, status=status.HTTP_400_BAD_REQUEST, ) - @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) - def retrieve(self, request, slug, project_id, pk=None): - page = self.get_queryset().filter(pk=pk).first() + def retrieve(self, request, slug, project_id, page_id=None): + page = self.get_queryset().filter(pk=page_id).first() project = Project.objects.get(pk=project_id) track_visit = request.query_params.get("track_visit", "true").lower() == "true" @@ -227,7 +221,7 @@ class PageViewSet(BaseViewSet): ) else: issue_ids = PageLog.objects.filter( - page_id=pk, entity_name="issue" + page_id=page_id, entity_name="issue" ).values_list("entity_identifier", flat=True) data = PageDetailSerializer(page).data data["issue_ids"] = issue_ids @@ -235,26 +229,24 @@ class PageViewSet(BaseViewSet): recent_visited_task.delay( slug=slug, entity_name="page", - entity_identifier=pk, + entity_identifier=page_id, user_id=request.user.id, project_id=project_id, ) return Response(data, status=status.HTTP_200_OK) - @allow_permission([ROLE.ADMIN], model=Page, creator=True) - def lock(self, request, slug, project_id, pk): + def lock(self, request, slug, project_id, page_id): page = Page.objects.filter( - pk=pk, workspace__slug=slug, projects__id=project_id + pk=page_id, workspace__slug=slug, projects__id=project_id ).first() page.is_locked = True page.save() return Response(status=status.HTTP_204_NO_CONTENT) - @allow_permission([ROLE.ADMIN], model=Page, creator=True) - def unlock(self, request, slug, project_id, pk): + def unlock(self, request, slug, project_id, page_id): page = Page.objects.filter( - pk=pk, workspace__slug=slug, projects__id=project_id + pk=page_id, workspace__slug=slug, projects__id=project_id ).first() page.is_locked = False @@ -262,11 +254,10 @@ class PageViewSet(BaseViewSet): return Response(status=status.HTTP_204_NO_CONTENT) - @allow_permission([ROLE.ADMIN], model=Page, creator=True) - def access(self, request, slug, project_id, pk): + def access(self, request, slug, project_id, page_id): access = request.data.get("access", 0) page = Page.objects.filter( - pk=pk, workspace__slug=slug, projects__id=project_id + pk=page_id, workspace__slug=slug, projects__id=project_id ).first() # Only update access if the page owner is the requesting user @@ -275,9 +266,7 @@ class PageViewSet(BaseViewSet): and page.owned_by_id != request.user.id ): return Response( - { - "error": "Access cannot be updated since this page is owned by someone else" - }, + {"error": "Access cannot be updated since this page is owned by someone else"}, status=status.HTTP_400_BAD_REQUEST, ) @@ -285,7 +274,6 @@ class PageViewSet(BaseViewSet): page.save() return Response(status=status.HTTP_204_NO_CONTENT) - @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) def list(self, request, slug, project_id): queryset = self.get_queryset() project = Project.objects.get(pk=project_id) @@ -303,9 +291,10 @@ class PageViewSet(BaseViewSet): pages = PageSerializer(queryset, many=True).data return Response(pages, status=status.HTTP_200_OK) - @allow_permission([ROLE.ADMIN], model=Page, creator=True) - def archive(self, request, slug, project_id, pk): - page = Page.objects.get(pk=pk, workspace__slug=slug, projects__id=project_id) + def archive(self, request, slug, project_id, page_id): + page = Page.objects.get( + pk=page_id, workspace__slug=slug, projects__id=project_id + ) # only the owner or admin can archive the page if ( @@ -321,18 +310,19 @@ class PageViewSet(BaseViewSet): UserFavorite.objects.filter( entity_type="page", - entity_identifier=pk, + entity_identifier=page_id, project_id=project_id, workspace__slug=slug, ).delete() - unarchive_archive_page_and_descendants(pk, datetime.now()) + unarchive_archive_page_and_descendants(page_id, datetime.now()) return Response({"archived_at": str(datetime.now())}, status=status.HTTP_200_OK) - @allow_permission([ROLE.ADMIN], model=Page, creator=True) - def unarchive(self, request, slug, project_id, pk): - page = Page.objects.get(pk=pk, workspace__slug=slug, projects__id=project_id) + def unarchive(self, request, slug, project_id, page_id): + page = Page.objects.get( + pk=page_id, workspace__slug=slug, projects__id=project_id + ) # only the owner or admin can un archive the page if ( @@ -346,18 +336,19 @@ class PageViewSet(BaseViewSet): status=status.HTTP_400_BAD_REQUEST, ) - # if parent page is archived then the page will be un archived breaking the hierarchy + # if parent archived then page will be un archived breaking hierarchy if page.parent_id and page.parent.archived_at: page.parent = None page.save(update_fields=["parent"]) - unarchive_archive_page_and_descendants(pk, None) + unarchive_archive_page_and_descendants(page_id, None) return Response(status=status.HTTP_204_NO_CONTENT) - @allow_permission([ROLE.ADMIN], model=Page, creator=True) - def destroy(self, request, slug, project_id, pk): - page = Page.objects.get(pk=pk, workspace__slug=slug, projects__id=project_id) + def destroy(self, request, slug, project_id, page_id): + page = Page.objects.get( + pk=page_id, workspace__slug=slug, projects__id=project_id + ) if page.archived_at is None: return Response( @@ -381,7 +372,7 @@ class PageViewSet(BaseViewSet): # remove parent from all the children _ = Page.objects.filter( - parent_id=pk, projects__id=project_id, workspace__slug=slug + parent_id=page_id, projects__id=project_id, workspace__slug=slug ).update(parent=None) page.delete() @@ -389,14 +380,14 @@ class PageViewSet(BaseViewSet): UserFavorite.objects.filter( project=project_id, workspace__slug=slug, - entity_identifier=pk, + entity_identifier=page_id, entity_type="page", ).delete() # Delete the page from recent visit UserRecentVisit.objects.filter( project_id=project_id, workspace__slug=slug, - entity_identifier=pk, + entity_identifier=page_id, entity_name="page", ).delete(soft=False) return Response(status=status.HTTP_204_NO_CONTENT) @@ -406,88 +397,36 @@ class PageFavoriteViewSet(BaseViewSet): model = UserFavorite @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) - def create(self, request, slug, project_id, pk): + def create(self, request, slug, project_id, page_id): _ = UserFavorite.objects.create( project_id=project_id, - entity_identifier=pk, + entity_identifier=page_id, entity_type="page", user=request.user, ) return Response(status=status.HTTP_204_NO_CONTENT) @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) - def destroy(self, request, slug, project_id, pk): + def destroy(self, request, slug, project_id, page_id): page_favorite = UserFavorite.objects.get( project=project_id, user=request.user, workspace__slug=slug, - entity_identifier=pk, + entity_identifier=page_id, entity_type="page", ) page_favorite.delete(soft=False) return Response(status=status.HTTP_204_NO_CONTENT) -class PageLogEndpoint(BaseAPIView): - serializer_class = PageLogSerializer - model = PageLog - - def post(self, request, slug, project_id, page_id): - serializer = PageLogSerializer(data=request.data) - if serializer.is_valid(): - serializer.save(project_id=project_id, page_id=page_id) - return Response(serializer.data, status=status.HTTP_201_CREATED) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - - def patch(self, request, slug, project_id, page_id, transaction): - page_transaction = PageLog.objects.get( - workspace__slug=slug, - project_id=project_id, - page_id=page_id, - transaction=transaction, - ) - serializer = PageLogSerializer( - page_transaction, data=request.data, partial=True - ) - if serializer.is_valid(): - serializer.save() - return Response(serializer.data, status=status.HTTP_200_OK) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - - def delete(self, request, slug, project_id, page_id, transaction): - transaction = PageLog.objects.get( - workspace__slug=slug, - project_id=project_id, - page_id=page_id, - transaction=transaction, - ) - # Delete the transaction object - transaction.delete() - return Response(status=status.HTTP_204_NO_CONTENT) - - -class SubPagesEndpoint(BaseAPIView): - @method_decorator(gzip_page) - def get(self, request, slug, project_id, page_id): - pages = ( - PageLog.objects.filter( - page_id=page_id, - workspace__slug=slug, - entity_name__in=["forward_link", "back_link"], - ) - .select_related("project") - .select_related("workspace") - ) - return Response( - SubPageSerializer(pages, many=True).data, status=status.HTTP_200_OK - ) - - class PagesDescriptionViewSet(BaseViewSet): - @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) - def retrieve(self, request, slug, project_id, pk): + permission_classes = [ProjectPagePermission] + + def retrieve(self, request, slug, project_id, page_id): page = ( - Page.objects.filter(pk=pk, workspace__slug=slug, projects__id=project_id) + Page.objects.filter( + pk=page_id, workspace__slug=slug, projects__id=project_id + ) .filter(Q(owned_by=self.request.user) | Q(access=0)) .first() ) @@ -507,10 +446,11 @@ class PagesDescriptionViewSet(BaseViewSet): response["Content-Disposition"] = 'attachment; filename="page_description.bin"' return response - @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) - def partial_update(self, request, slug, project_id, pk): + def partial_update(self, request, slug, project_id, page_id): page = ( - Page.objects.filter(pk=pk, workspace__slug=slug, projects__id=project_id) + Page.objects.filter( + pk=page_id, workspace__slug=slug, projects__id=project_id + ) .filter(Q(owned_by=self.request.user) | Q(access=0)) .first() ) @@ -547,7 +487,7 @@ class PagesDescriptionViewSet(BaseViewSet): # Capture the page transaction if request.data.get("description_html"): page_transaction.delay( - new_value=request.data, old_value=existing_instance, page_id=pk + new_value=request.data, old_value=existing_instance, page_id=page_id ) # Update the page using serializer @@ -565,7 +505,7 @@ class PagesDescriptionViewSet(BaseViewSet): class PageDuplicateEndpoint(BaseAPIView): - @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) + permission_classes = [ProjectPagePermission] def post(self, request, slug, project_id, page_id): page = Page.objects.filter( pk=page_id, workspace__slug=slug, projects__id=project_id diff --git a/apps/api/plane/app/views/page/version.py b/apps/api/plane/app/views/page/version.py index bcf2f4f5b..15aa3a152 100644 --- a/apps/api/plane/app/views/page/version.py +++ b/apps/api/plane/app/views/page/version.py @@ -7,10 +7,12 @@ from plane.db.models import PageVersion from ..base import BaseAPIView from plane.app.serializers import PageVersionSerializer, PageVersionDetailSerializer from plane.app.permissions import allow_permission, ROLE - +from plane.app.permissions import ProjectPagePermission class PageVersionEndpoint(BaseAPIView): - @allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) + + permission_classes = [ProjectPagePermission] + def get(self, request, slug, project_id, page_id, pk=None): # Check if pk is provided if pk: