* [WEB-8095] fix: scope page-version reads to the URL project (GHSA-g49r/ghcr) ProjectPagePermission verified the caller was a member of the URL project_id but then resolved the page by workspace + page_id only, and PageVersionEndpoint filtered versions the same way. A member of one project could read the page versions of a public page belonging to a different project in the same workspace via that project's URL (GHSA-g49r-p85q-qq2w / GHSA-ghcr-frqr-6pqr). - Scope the page lookup in ProjectPagePermission to projects__id via the ProjectPage M2M (both app/ and utils/ copies); deny when the page does not belong to the URL project. - Scope PageVersionEndpoint list/detail querysets to page__projects__id=project_id (defense in depth); distinct() on the list guards against active + soft-deleted ProjectPage duplicates. - Add contract regression tests (fail-before verified). Co-authored-by: Plane AI <noreply@plane.so> * [WEB-8095] fix: require active ProjectPage link when scoping pages to a project Address CodeRabbit + Copilot review on #9380: projects__id=project_id matched even soft-deleted ProjectPage links, so a page removed from the project (link revoked) would still pass, and the version detail get() could raise MultipleObjectsReturned on active + soft-deleted rows. Put both conditions on the same project_pages relation in one filter so they match a single ProjectPage row that is active: project_pages__project_id=project_id + project_pages__deleted_at__isnull =True. The partial-unique constraint (project, page WHERE deleted_at IS NULL) then guarantees at most one row, so get() stays unambiguous and the list needs no distinct(). Add a revoked-link regression test. Co-authored-by: Plane AI <noreply@plane.so> * [WEB-8095] fix: distinct() on page-version detail lookup as a MultipleObjectsReturned guard Address CodeRabbit review on #9380. The active-link filter already keeps the page__project_pages join to a single row via the partial-unique constraint, but add distinct() to the detail get() as defense in depth so the join can never surface MultipleObjectsReturned (a 500) even if that invariant were ever violated. Co-authored-by: Plane AI <noreply@plane.so> --------- Co-authored-by: Plane AI <noreply@plane.so>
138 lines
4.6 KiB
Python
138 lines
4.6 KiB
Python
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
|
# SPDX-License-Identifier: AGPL-3.0-only
|
|
# See the LICENSE file for details.
|
|
|
|
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:
|
|
# Scope the page to the project in the URL. Resolving the page by
|
|
# workspace + page_id alone allowed a member of one project to read
|
|
# pages belonging to another project in the same workspace
|
|
# (GHSA-g49r / GHSA-ghcr). Require an *active* ProjectPage link (both
|
|
# conditions on the same relation so they match one row) so a page
|
|
# removed from the project (soft-deleted link) is also denied.
|
|
page = Page.objects.filter(
|
|
id=page_id,
|
|
workspace__slug=slug,
|
|
project_pages__project_id=project_id,
|
|
project_pages__deleted_at__isnull=True,
|
|
).first()
|
|
if page is None:
|
|
return False
|
|
|
|
# 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
|