From da22c82c174a190e264b88a741f11ef92bb1d8a6 Mon Sep 17 00:00:00 2001 From: sriram veeraghanta Date: Mon, 13 Apr 2026 00:43:24 +0530 Subject: [PATCH 1/3] feat: add WorkspaceAdminOnlyPermission for external API access control Add API-specific permission classes that provide stricter access control for external API endpoints, replacing the broader WorkSpaceAdminPermission. --- apps/api/plane/api/permissions/__init__.py | 1 + apps/api/plane/api/permissions/workspace.py | 54 +++++++++++++++++++++ 2 files changed, 55 insertions(+) create mode 100644 apps/api/plane/api/permissions/__init__.py create mode 100644 apps/api/plane/api/permissions/workspace.py diff --git a/apps/api/plane/api/permissions/__init__.py b/apps/api/plane/api/permissions/__init__.py new file mode 100644 index 00000000000..2162dc50095 --- /dev/null +++ b/apps/api/plane/api/permissions/__init__.py @@ -0,0 +1 @@ +from .workspace import WorkspaceAdminOnlyPermission, WorkspaceAdminWriteMemberReadPermission diff --git a/apps/api/plane/api/permissions/workspace.py b/apps/api/plane/api/permissions/workspace.py new file mode 100644 index 00000000000..25fec0f2d64 --- /dev/null +++ b/apps/api/plane/api/permissions/workspace.py @@ -0,0 +1,54 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + +from rest_framework.permissions import BasePermission, SAFE_METHODS + +from plane.db.models import WorkspaceMember + + +Admin = 20 +Member = 15 + + +class WorkspaceAdminOnlyPermission(BasePermission): + """ + Permission class for external APIs that restricts access to workspace admins only. + """ + + def has_permission(self, request, view): + if request.user.is_anonymous: + return False + + return WorkspaceMember.objects.filter( + member=request.user, + workspace__slug=view.workspace_slug, + role=Admin, + is_active=True, + ).exists() + + +class WorkspaceAdminWriteMemberReadPermission(BasePermission): + """ + Permission class for external APIs that allows workspace members to read + but restricts write operations to workspace admins only. + """ + + def has_permission(self, request, view): + if request.user.is_anonymous: + return False + + if request.method in SAFE_METHODS: + return WorkspaceMember.objects.filter( + member=request.user, + workspace__slug=view.workspace_slug, + role__in=[Admin, Member], + is_active=True, + ).exists() + + return WorkspaceMember.objects.filter( + member=request.user, + workspace__slug=view.workspace_slug, + role=Admin, + is_active=True, + ).exists() From 14f08e20363a8f5ed46d46ae03d5a74350c3e109 Mon Sep 17 00:00:00 2001 From: sriram veeraghanta Date: Mon, 15 Jun 2026 10:53:44 +0530 Subject: [PATCH 2/3] fix(api): close cross-workspace/cross-project asset IDOR & missing-authz (advisory cluster F) Enforce object-level authorization consistently across every asset and attachment surface that previously trusted caller-supplied workspace/ project/issue identifiers: - WorkspaceFileAssetEndpoint: add workspace-membership checks to all methods (were IsAuthenticated-only); gate logo/cover mutation to admins; block reads/downloads of project-bound assets by non-project members (GHSA-3ggg, GHSA-8chr, GHSA-58qm, GHSA-p57q, GHSA-9gxc, GHSA-wrrw, GHSA-85h2) - DuplicateAssetEndpoint: scope source asset to the caller's workspaces and owning-project membership (GHSA-3892, GHSA-c68q, GHSA-58qm) - ProjectBulkAssetEndpoint: scope lookup to the URL project_id (GHSA-r2hw) - WorkspaceAssetDownloadEndpoint: enforce project membership (GHSA-wrrw, GHSA-85h2) - v1 FileAssetEndpoint: add workspace-membership guards (GHSA-3hrj, GHSA-p57q) - allow_permission creator check: scope by workspace slug (GHSA-3hrj) - Spaces public asset endpoints: scope get/patch/restore to the board's project (GHSA-jh4v, GHSA-j4mj) - Spaces issue comment/reaction/vote create: verify target belongs to the board's project (GHSA-x8wp) - IssueAttachmentV2Endpoint: include issue_id in lookups (GHSA-5mxw) - External API attachment list/detail: add ProjectEntityPermission (GHSA-hc4r) Adds regression tests in contract/app/test_asset_authz.py. --- apps/api/plane/api/views/issue.py | 2 + apps/api/plane/app/permissions/base.py | 6 +- apps/api/plane/app/views/asset/base.py | 34 ++- apps/api/plane/app/views/asset/v2.py | 124 ++++++++++- apps/api/plane/app/views/issue/attachment.py | 6 +- apps/api/plane/space/views/asset.py | 14 +- apps/api/plane/space/views/issue.py | 16 ++ .../tests/contract/app/test_asset_authz.py | 193 ++++++++++++++++++ 8 files changed, 381 insertions(+), 14 deletions(-) create mode 100644 apps/api/plane/tests/contract/app/test_asset_authz.py diff --git a/apps/api/plane/api/views/issue.py b/apps/api/plane/api/views/issue.py index 97e8e7cee0a..6453d23a3de 100644 --- a/apps/api/plane/api/views/issue.py +++ b/apps/api/plane/api/views/issue.py @@ -1769,6 +1769,7 @@ class IssueAttachmentListCreateAPIEndpoint(BaseAPIView): serializer_class = IssueAttachmentSerializer model = FileAsset + permission_classes = [ProjectEntityPermission] use_read_replica = True @issue_attachment_docs( @@ -1984,6 +1985,7 @@ class IssueAttachmentDetailAPIEndpoint(BaseAPIView): serializer_class = IssueAttachmentSerializer model = FileAsset + permission_classes = [ProjectEntityPermission] use_read_replica = True @issue_attachment_docs( diff --git a/apps/api/plane/app/permissions/base.py b/apps/api/plane/app/permissions/base.py index 9c451ed86ab..36871b8307c 100644 --- a/apps/api/plane/app/permissions/base.py +++ b/apps/api/plane/app/permissions/base.py @@ -33,7 +33,11 @@ def _wrapped_view(instance, request, *args, **kwargs): status=status.HTTP_403_FORBIDDEN, ) - obj = model.objects.filter(id=kwargs["pk"], created_by=request.user).exists() + obj = model.objects.filter( + id=kwargs["pk"], + created_by=request.user, + workspace__slug=kwargs["slug"], + ).exists() if obj: return view_func(instance, request, *args, **kwargs) diff --git a/apps/api/plane/app/views/asset/base.py b/apps/api/plane/app/views/asset/base.py index 5b55a76a611..0d4439c73f6 100644 --- a/apps/api/plane/app/views/asset/base.py +++ b/apps/api/plane/app/views/asset/base.py @@ -9,10 +9,22 @@ # Module imports from ..base import BaseAPIView, BaseViewSet -from plane.db.models import FileAsset, Workspace +from plane.db.models import FileAsset, Workspace, WorkspaceMember from plane.app.serializers import FileAssetSerializer +def _is_workspace_member(user, *, workspace_id=None, slug=None): + """Return True when the user is an active member of the given workspace.""" + if user.is_anonymous: + return False + filters = {"member": user, "is_active": True} + if workspace_id is not None: + filters["workspace_id"] = workspace_id + if slug is not None: + filters["workspace__slug"] = slug + return WorkspaceMember.objects.filter(**filters).exists() + + class FileAssetEndpoint(BaseAPIView): parser_classes = (MultiPartParser, FormParser, JSONParser) @@ -21,6 +33,11 @@ class FileAssetEndpoint(BaseAPIView): """ def get(self, request, workspace_id, asset_key): + if not _is_workspace_member(request.user, workspace_id=workspace_id): + return Response( + {"error": "You don't have the required permissions."}, + status=status.HTTP_403_FORBIDDEN, + ) asset_key = str(workspace_id) + "/" + asset_key files = FileAsset.objects.filter(asset=asset_key) if files.exists(): @@ -33,6 +50,11 @@ def get(self, request, workspace_id, asset_key): ) def post(self, request, slug): + if not _is_workspace_member(request.user, slug=slug): + return Response( + {"error": "You don't have the required permissions."}, + status=status.HTTP_403_FORBIDDEN, + ) serializer = FileAssetSerializer(data=request.data) if serializer.is_valid(): # Get the workspace @@ -42,6 +64,11 @@ def post(self, request, slug): return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) def delete(self, request, workspace_id, asset_key): + if not _is_workspace_member(request.user, workspace_id=workspace_id): + return Response( + {"error": "You don't have the required permissions."}, + status=status.HTTP_403_FORBIDDEN, + ) asset_key = str(workspace_id) + "/" + asset_key file_asset = FileAsset.objects.get(asset=asset_key) file_asset.is_deleted = True @@ -51,6 +78,11 @@ def delete(self, request, workspace_id, asset_key): class FileAssetViewSet(BaseViewSet): def restore(self, request, workspace_id, asset_key): + if not _is_workspace_member(request.user, workspace_id=workspace_id): + return Response( + {"error": "You don't have the required permissions."}, + status=status.HTTP_403_FORBIDDEN, + ) asset_key = str(workspace_id) + "/" + asset_key file_asset = FileAsset.objects.get(asset=asset_key) file_asset.is_deleted = False diff --git a/apps/api/plane/app/views/asset/v2.py b/apps/api/plane/app/views/asset/v2.py index 62c5f84a20b..e1068b16886 100644 --- a/apps/api/plane/app/views/asset/v2.py +++ b/apps/api/plane/app/views/asset/v2.py @@ -18,7 +18,14 @@ # Module imports from ..base import BaseAPIView -from plane.db.models import FileAsset, Workspace, Project, User +from plane.db.models import ( + FileAsset, + Workspace, + Project, + User, + WorkspaceMember, + ProjectMember, +) from plane.settings.storage import S3Storage from plane.app.permissions import allow_permission, ROLE from plane.utils.cache import invalidate_cache_directly @@ -201,6 +208,52 @@ def delete(self, request, asset_id): class WorkspaceFileAssetEndpoint(BaseAPIView): """This endpoint is used to upload cover images/logos etc for workspace, projects and users.""" + def is_workspace_admin(self, request, slug): + return WorkspaceMember.objects.filter( + member=request.user, + workspace__slug=slug, + role=ROLE.ADMIN.value, + is_active=True, + ).exists() + + def is_project_admin(self, request, slug, project_id): + if not project_id: + return False + return ProjectMember.objects.filter( + member=request.user, + workspace__slug=slug, + project_id=project_id, + role=ROLE.ADMIN.value, + is_active=True, + ).exists() + + def has_project_access(self, request, slug, project_id): + if not project_id: + return False + return ProjectMember.objects.filter( + member=request.user, + workspace__slug=slug, + project_id=project_id, + is_active=True, + ).exists() + + def authorize_entity_mutation(self, request, slug, entity_type, project_id): + """Logo/cover mutations require admin on the owning scope. Returns an error + response when the caller is not authorized, otherwise None.""" + if entity_type == FileAsset.EntityTypeContext.WORKSPACE_LOGO and not self.is_workspace_admin(request, slug): + return Response( + {"error": "You don't have the required permissions."}, + status=status.HTTP_403_FORBIDDEN, + ) + if entity_type == FileAsset.EntityTypeContext.PROJECT_COVER and not self.is_project_admin( + request, slug, project_id + ): + return Response( + {"error": "You don't have the required permissions."}, + status=status.HTTP_403_FORBIDDEN, + ) + return None + def get_entity_id_field(self, entity_type, entity_id): # Workspace Logo if entity_type == FileAsset.EntityTypeContext.WORKSPACE_LOGO: @@ -311,6 +364,7 @@ def entity_asset_delete(self, entity_type, asset, request): else: return + @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE") def post(self, request, slug): name = request.data.get("name") type = request.data.get("type", "image/jpeg") @@ -325,6 +379,16 @@ def post(self, request, slug): status=status.HTTP_400_BAD_REQUEST, ) + # Logo/cover assets may only be created by an admin of the owning scope + project_identifier = entity_identifier if entity_type == FileAsset.EntityTypeContext.PROJECT_COVER else None + if entity_type == FileAsset.EntityTypeContext.PROJECT_COVER and not Project.objects.filter( + id=project_identifier, workspace__slug=slug + ).exists(): + return Response({"error": "Project not found"}, status=status.HTTP_404_NOT_FOUND) + unauthorized = self.authorize_entity_mutation(request, slug, entity_type, project_identifier) + if unauthorized: + return unauthorized + # Check if the file type is allowed allowed_types = [ "image/jpeg", @@ -376,9 +440,14 @@ def post(self, request, slug): status=status.HTTP_200_OK, ) + @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE") def patch(self, request, slug, asset_id): # get the asset id asset = FileAsset.objects.get(id=asset_id, workspace__slug=slug) + # Logo/cover mutations require admin on the owning scope + unauthorized = self.authorize_entity_mutation(request, slug, asset.entity_type, asset.project_id) + if unauthorized: + return unauthorized # get the storage metadata asset.is_uploaded = True # get the storage metadata @@ -397,8 +466,13 @@ def patch(self, request, slug, asset_id): asset.save(update_fields=["is_uploaded", "attributes"]) return Response(status=status.HTTP_204_NO_CONTENT) + @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE") def delete(self, request, slug, asset_id): asset = FileAsset.objects.get(id=asset_id, workspace__slug=slug) + # Logo/cover mutations require admin on the owning scope + unauthorized = self.authorize_entity_mutation(request, slug, asset.entity_type, asset.project_id) + if unauthorized: + return unauthorized asset.is_deleted = True asset.deleted_at = timezone.now() # get the entity and save the asset id for the request field @@ -406,10 +480,19 @@ def delete(self, request, slug, asset_id): asset.save(update_fields=["is_deleted", "deleted_at"]) return Response(status=status.HTTP_204_NO_CONTENT) + @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE") def get(self, request, slug, asset_id): # get the asset id asset = FileAsset.objects.get(id=asset_id, workspace__slug=slug) + # Project-bound assets must not be readable by workspace members who are + # not members of the owning project (e.g. secret projects). + if asset.project_id and not self.has_project_access(request, slug, asset.project_id): + return Response( + {"error": "The requested asset could not be found."}, + status=status.HTTP_404_NOT_FOUND, + ) + # Check if the asset is uploaded if not asset.is_uploaded: return Response( @@ -641,8 +724,9 @@ def post(self, request, slug, project_id, entity_id): if not asset_ids: return Response({"error": "No asset ids provided."}, status=status.HTTP_400_BAD_REQUEST) - # get the asset id - assets = FileAsset.objects.filter(id__in=asset_ids, workspace__slug=slug) + # get the asset id — scope to the URL project so assets from sibling + # projects in the same workspace cannot be hijacked/reassigned. + assets = FileAsset.objects.filter(id__in=asset_ids, workspace__slug=slug, project_id=project_id) # Get the first asset asset = assets.first() @@ -752,11 +836,27 @@ def post(self, request, slug, asset_id): return Response({"error": "Project not found"}, status=status.HTTP_404_NOT_FOUND) storage = S3Storage(request=request) - original_asset = FileAsset.objects.filter(id=asset_id, is_uploaded=True).first() + # Scope the source asset to workspaces the caller is an active member of, + # so a known asset UUID cannot be copied out of another tenant. + member_workspace_ids = WorkspaceMember.objects.filter( + member=request.user, is_active=True + ).values_list("workspace_id", flat=True) + original_asset = FileAsset.objects.filter( + id=asset_id, is_uploaded=True, workspace_id__in=member_workspace_ids + ).first() if not original_asset: return Response({"error": "Asset not found"}, status=status.HTTP_404_NOT_FOUND) + # If the source asset belongs to a project, the caller must be a member of + # that project (guards secret-project assets from cross-project duplication). + if original_asset.project_id and not ProjectMember.objects.filter( + member=request.user, + project_id=original_asset.project_id, + is_active=True, + ).exists(): + return Response({"error": "Asset not found"}, status=status.HTTP_404_NOT_FOUND) + destination_key = f"{workspace.id}/{uuid.uuid4().hex}-{original_asset.attributes.get('name')}" duplicated_asset = FileAsset.objects.create( attributes={ @@ -797,6 +897,22 @@ def get(self, request, slug, asset_id): status=status.HTTP_404_NOT_FOUND, ) + # Project-bound assets must not be downloadable by workspace members who + # are not members of the owning project (e.g. secret projects). + if ( + asset.project_id + and not ProjectMember.objects.filter( + member=request.user, + workspace__slug=slug, + project_id=asset.project_id, + is_active=True, + ).exists() + ): + return Response( + {"error": "The requested asset could not be found."}, + status=status.HTTP_404_NOT_FOUND, + ) + storage = S3Storage(request=request) signed_url = storage.generate_presigned_url( object_name=asset.asset.name, diff --git a/apps/api/plane/app/views/issue/attachment.py b/apps/api/plane/app/views/issue/attachment.py index df027c413b1..f6b2aa633e3 100644 --- a/apps/api/plane/app/views/issue/attachment.py +++ b/apps/api/plane/app/views/issue/attachment.py @@ -147,7 +147,7 @@ def post(self, request, slug, project_id, issue_id): @allow_permission([ROLE.ADMIN], creator=True, model=FileAsset) def delete(self, request, slug, project_id, issue_id, pk): - issue_attachment = FileAsset.objects.get(pk=pk, workspace__slug=slug, project_id=project_id) + issue_attachment = FileAsset.objects.get(pk=pk, workspace__slug=slug, project_id=project_id, issue_id=issue_id) issue_attachment.is_deleted = True issue_attachment.deleted_at = timezone.now() issue_attachment.save() @@ -170,7 +170,7 @@ def delete(self, request, slug, project_id, issue_id, pk): def get(self, request, slug, project_id, issue_id, pk=None): if pk: # Get the asset - asset = FileAsset.objects.get(id=pk, workspace__slug=slug, project_id=project_id) + asset = FileAsset.objects.get(id=pk, workspace__slug=slug, project_id=project_id, issue_id=issue_id) # Check if the asset is uploaded if not asset.is_uploaded: @@ -201,7 +201,7 @@ def get(self, request, slug, project_id, issue_id, pk=None): @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) def patch(self, request, slug, project_id, issue_id, pk): - issue_attachment = FileAsset.objects.get(pk=pk, workspace__slug=slug, project_id=project_id) + issue_attachment = FileAsset.objects.get(pk=pk, workspace__slug=slug, project_id=project_id, issue_id=issue_id) serializer = IssueAttachmentSerializer(issue_attachment) # Send this activity only if the attachment is not uploaded before diff --git a/apps/api/plane/space/views/asset.py b/apps/api/plane/space/views/asset.py index 1749a8fd462..eb2acfbc7a8 100644 --- a/apps/api/plane/space/views/asset.py +++ b/apps/api/plane/space/views/asset.py @@ -41,9 +41,11 @@ def get(self, request, anchor, pk): status=status.HTTP_404_NOT_FOUND, ) - # get the asset id + # get the asset id — scope to the board's project so an anchor cannot + # reach assets of other (including unpublished/secret) projects. asset = FileAsset.objects.get( workspace_id=deploy_board.workspace_id, + project_id=deploy_board.project_id, pk=pk, entity_type__in=[ FileAsset.EntityTypeContext.ISSUE_DESCRIPTION, @@ -139,8 +141,9 @@ def patch(self, request, anchor, pk): if not deploy_board: return Response({"error": "Project is not published"}, status=status.HTTP_404_NOT_FOUND) - # get the asset id - asset = FileAsset.objects.get(id=pk, workspace=deploy_board.workspace) + # get the asset id — scope to the board's project so an anchor cannot + # mutate assets of other projects in the same workspace. + asset = FileAsset.objects.get(id=pk, workspace=deploy_board.workspace, project_id=deploy_board.project_id) # get the storage metadata asset.is_uploaded = True # get the storage metadata @@ -179,8 +182,9 @@ def post(self, request, anchor, pk): if not deploy_board: return Response({"error": "Project is not published"}, status=status.HTTP_404_NOT_FOUND) - # Get the asset - asset = FileAsset.all_objects.get(id=pk, workspace=deploy_board.workspace) + # Get the asset — scope to the board's project so an anchor cannot + # restore assets of other projects in the same workspace. + asset = FileAsset.all_objects.get(id=pk, workspace=deploy_board.workspace, project_id=deploy_board.project_id) asset.is_deleted = False asset.deleted_at = None asset.save(update_fields=["is_deleted", "deleted_at"]) diff --git a/apps/api/plane/space/views/issue.py b/apps/api/plane/space/views/issue.py index 9e2187466aa..28f72c00970 100644 --- a/apps/api/plane/space/views/issue.py +++ b/apps/api/plane/space/views/issue.py @@ -263,6 +263,11 @@ def create(self, request, anchor, issue_id): status=status.HTTP_400_BAD_REQUEST, ) + # The target issue must belong to the board's project; otherwise an + # attacker could attach comments to issues in other projects/workspaces. + if not Issue.objects.filter(id=issue_id, project_id=project_deploy_board.project_id).exists(): + return Response({"error": "Issue not found"}, status=status.HTTP_404_NOT_FOUND) + serializer = IssueCommentSerializer(data=request.data) if serializer.is_valid(): serializer.save( @@ -372,6 +377,10 @@ def create(self, request, anchor, issue_id): status=status.HTTP_400_BAD_REQUEST, ) + # The target issue must belong to the board's project. + if not Issue.objects.filter(id=issue_id, project_id=project_deploy_board.project_id).exists(): + return Response({"error": "Issue not found"}, status=status.HTTP_404_NOT_FOUND) + serializer = IssueReactionSerializer(data=request.data) if serializer.is_valid(): serializer.save( @@ -457,6 +466,10 @@ def create(self, request, anchor, comment_id): status=status.HTTP_400_BAD_REQUEST, ) + # The target comment must belong to the board's project. + if not IssueComment.objects.filter(id=comment_id, project_id=project_deploy_board.project_id).exists(): + return Response({"error": "Comment not found"}, status=status.HTTP_404_NOT_FOUND) + serializer = CommentReactionSerializer(data=request.data) if serializer.is_valid(): serializer.save( @@ -542,6 +555,9 @@ def get_queryset(self): def create(self, request, anchor, issue_id): project_deploy_board = DeployBoard.objects.get(anchor=anchor, entity_name="project") + # The target issue must belong to the board's project. + if not Issue.objects.filter(id=issue_id, project_id=project_deploy_board.project_id).exists(): + return Response({"error": "Issue not found"}, status=status.HTTP_404_NOT_FOUND) issue_vote, _ = IssueVote.objects.get_or_create( actor_id=request.user.id, project_id=project_deploy_board.project_id, diff --git a/apps/api/plane/tests/contract/app/test_asset_authz.py b/apps/api/plane/tests/contract/app/test_asset_authz.py new file mode 100644 index 00000000000..c4af8d7a194 --- /dev/null +++ b/apps/api/plane/tests/contract/app/test_asset_authz.py @@ -0,0 +1,193 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + +"""Regression tests for the cross-workspace / cross-project asset +authorization fixes (advisory cluster F).""" + +import uuid + +import pytest +from rest_framework import status + +from plane.db.models import ( + FileAsset, + Project, + ProjectMember, + User, + Workspace, + WorkspaceMember, +) + + +def make_asset(workspace, *, project=None, entity_type=None, is_uploaded=True): + entity_type = entity_type or FileAsset.EntityTypeContext.ISSUE_ATTACHMENT + return FileAsset.objects.create( + asset=f"{workspace.id}/{uuid.uuid4().hex}-test.png", + workspace=workspace, + project=project, + entity_type=entity_type, + size=100, + is_uploaded=is_uploaded, + attributes={"name": "test.png", "type": "image/png", "size": 100}, + ) + + +class AssetURLMixin: + def workspace_asset_url(self, slug, asset_id=None): + base = f"/api/assets/v2/workspaces/{slug}/" + return f"{base}{asset_id}/" if asset_id else base + + def workspace_download_url(self, slug, asset_id): + return f"/api/assets/v2/workspaces/{slug}/download/{asset_id}/" + + def duplicate_url(self, slug, asset_id): + return f"/api/assets/v2/workspaces/{slug}/duplicate-assets/{asset_id}/" + + def project_bulk_url(self, slug, project_id, entity_id): + return f"/api/assets/v2/workspaces/{slug}/projects/{project_id}/{entity_id}/bulk/" + + +@pytest.mark.contract +class TestWorkspaceFileAssetAuthz(AssetURLMixin): + """WorkspaceFileAssetEndpoint previously had no membership check at all.""" + + @pytest.mark.django_db + def test_post_denied_for_non_member(self, session_client, workspace): + outsider = User.objects.create_user(email="outsider@example.com", username="outsider") + session_client.force_authenticate(user=outsider) + + response = session_client.post( + self.workspace_asset_url(workspace.slug), + { + "name": "logo.png", + "type": "image/png", + "size": 100, + "entity_type": "WORKSPACE_LOGO", + "entity_identifier": str(workspace.id), + }, + format="json", + ) + assert response.status_code == status.HTTP_403_FORBIDDEN + + @pytest.mark.django_db + def test_logo_post_requires_admin(self, session_client, workspace): + member = User.objects.create_user(email="member@example.com", username="member") + WorkspaceMember.objects.create(workspace=workspace, member=member, role=15, is_active=True) + session_client.force_authenticate(user=member) + + response = session_client.post( + self.workspace_asset_url(workspace.slug), + { + "name": "logo.png", + "type": "image/png", + "size": 100, + "entity_type": "WORKSPACE_LOGO", + "entity_identifier": str(workspace.id), + }, + format="json", + ) + assert response.status_code == status.HTTP_403_FORBIDDEN + + @pytest.mark.django_db + def test_get_denied_for_non_member(self, session_client, workspace): + asset = make_asset(workspace) + outsider = User.objects.create_user(email="outsider@example.com", username="outsider") + session_client.force_authenticate(user=outsider) + + response = session_client.get(self.workspace_asset_url(workspace.slug, asset.id)) + assert response.status_code == status.HTTP_403_FORBIDDEN + + @pytest.mark.django_db + def test_get_secret_project_asset_denied_for_non_project_member(self, session_client, workspace): + # Workspace member who is NOT a member of the (secret) project must not + # be able to download that project's asset (GHSA-wrrw / GHSA-85h2). + project = Project.objects.create(name="Secret", identifier="SEC", workspace=workspace, network=0) + asset = make_asset(workspace, project=project) + + ws_member = User.objects.create_user(email="wsmember@example.com", username="wsmember") + WorkspaceMember.objects.create(workspace=workspace, member=ws_member, role=15, is_active=True) + session_client.force_authenticate(user=ws_member) + + response = session_client.get(self.workspace_asset_url(workspace.slug, asset.id)) + assert response.status_code == status.HTTP_404_NOT_FOUND + + @pytest.mark.django_db + def test_workspace_download_secret_project_asset_denied(self, session_client, workspace): + project = Project.objects.create(name="Secret", identifier="SEC", workspace=workspace, network=0) + asset = make_asset(workspace, project=project) + + ws_member = User.objects.create_user(email="wsmember@example.com", username="wsmember") + WorkspaceMember.objects.create(workspace=workspace, member=ws_member, role=15, is_active=True) + session_client.force_authenticate(user=ws_member) + + response = session_client.get(self.workspace_download_url(workspace.slug, asset.id)) + assert response.status_code == status.HTTP_404_NOT_FOUND + + +@pytest.mark.contract +class TestDuplicateAssetAuthz(AssetURLMixin): + @pytest.mark.django_db + def test_cross_workspace_duplication_denied(self, session_client, workspace, create_user): + # Victim asset lives in `workspace` (attacker is not a member). + victim_asset = make_asset(workspace) + + # Attacker controls their own workspace. + attacker = User.objects.create_user(email="attacker@example.com", username="attacker") + attacker_ws = Workspace.objects.create(name="Attacker", owner=attacker, slug="attacker-ws") + WorkspaceMember.objects.create(workspace=attacker_ws, member=attacker, role=20, is_active=True) + session_client.force_authenticate(user=attacker) + + before = FileAsset.objects.count() + response = session_client.post( + self.duplicate_url(attacker_ws.slug, victim_asset.id), + {"entity_type": "ISSUE_ATTACHMENT"}, + format="json", + ) + assert response.status_code == status.HTTP_404_NOT_FOUND + # No copy was created. + assert FileAsset.objects.count() == before + + +@pytest.mark.contract +class TestProjectBulkAssetAuthz(AssetURLMixin): + @pytest.mark.django_db + def test_cross_project_reassignment_denied(self, session_client, workspace): + attacker = User.objects.create_user(email="attacker@example.com", username="attacker") + WorkspaceMember.objects.create(workspace=workspace, member=attacker, role=15, is_active=True) + + attacker_project = Project.objects.create(name="Attacker", identifier="ATK", workspace=workspace) + ProjectMember.objects.create(project=attacker_project, member=attacker, role=20, is_active=True) + + victim_project = Project.objects.create(name="Victim", identifier="VIC", workspace=workspace) + victim_asset = make_asset( + workspace, project=victim_project, entity_type=FileAsset.EntityTypeContext.PROJECT_COVER + ) + + session_client.force_authenticate(user=attacker) + response = session_client.post( + self.project_bulk_url(workspace.slug, attacker_project.id, uuid.uuid4()), + {"asset_ids": [str(victim_asset.id)]}, + format="json", + ) + # Asset belongs to a different project -> not found, not reassigned. + assert response.status_code == status.HTTP_404_NOT_FOUND + victim_asset.refresh_from_db() + assert victim_asset.project_id == victim_project.id + + @pytest.mark.django_db + def test_same_project_reassignment_allowed(self, session_client, workspace): + member = User.objects.create_user(email="member@example.com", username="member") + WorkspaceMember.objects.create(workspace=workspace, member=member, role=15, is_active=True) + + project = Project.objects.create(name="Mine", identifier="MIN", workspace=workspace) + ProjectMember.objects.create(project=project, member=member, role=20, is_active=True) + asset = make_asset(workspace, project=project, entity_type=FileAsset.EntityTypeContext.PROJECT_COVER) + + session_client.force_authenticate(user=member) + response = session_client.post( + self.project_bulk_url(workspace.slug, project.id, uuid.uuid4()), + {"asset_ids": [str(asset.id)]}, + format="json", + ) + assert response.status_code == status.HTTP_204_NO_CONTENT From 38db42e389142046254218d91d6a91c5acd3a6c3 Mon Sep 17 00:00:00 2001 From: sriram veeraghanta Date: Mon, 15 Jun 2026 11:07:37 +0530 Subject: [PATCH 3/3] fix(api): address PR review + resolve merge conflict in asset duplicate endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - api/permissions/__init__.py: add missing AGPL license header (fixes failing addlicense CI check) - api/permissions/workspace.py: guard workspace_slug lookup to deny cleanly instead of raising AttributeError/500; use canonical ROLE enum instead of duplicated role literals; add method docstrings - app/views/asset/v2.py: resolve merge-conflict markers in DuplicateAssetEndpoint left by the preview merge — keep preview's source-workspace scoping + sanitize_filename and retain the owning-project membership guard --- apps/api/plane/api/permissions/__init__.py | 4 +++ apps/api/plane/api/permissions/workspace.py | 32 +++++++++++++++------ apps/api/plane/app/views/asset/v2.py | 15 +--------- 3 files changed, 29 insertions(+), 22 deletions(-) diff --git a/apps/api/plane/api/permissions/__init__.py b/apps/api/plane/api/permissions/__init__.py index 2162dc50095..e36c257a14c 100644 --- a/apps/api/plane/api/permissions/__init__.py +++ b/apps/api/plane/api/permissions/__init__.py @@ -1 +1,5 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + from .workspace import WorkspaceAdminOnlyPermission, WorkspaceAdminWriteMemberReadPermission diff --git a/apps/api/plane/api/permissions/workspace.py b/apps/api/plane/api/permissions/workspace.py index 25fec0f2d64..2aa24164496 100644 --- a/apps/api/plane/api/permissions/workspace.py +++ b/apps/api/plane/api/permissions/workspace.py @@ -4,11 +4,17 @@ from rest_framework.permissions import BasePermission, SAFE_METHODS +from plane.app.permissions import ROLE from plane.db.models import WorkspaceMember -Admin = 20 -Member = 15 +def get_workspace_slug(view): + """Resolve the workspace slug from the view, returning None when it is absent. + + Accessing ``view.workspace_slug`` directly would raise ``AttributeError`` (and a + 500) on a view that does not expose it; returning None lets the caller deny cleanly. + """ + return getattr(view, "workspace_slug", None) class WorkspaceAdminOnlyPermission(BasePermission): @@ -17,13 +23,18 @@ class WorkspaceAdminOnlyPermission(BasePermission): """ def has_permission(self, request, view): + """Allow only active workspace admins.""" if request.user.is_anonymous: return False + workspace_slug = get_workspace_slug(view) + if not workspace_slug: + return False + return WorkspaceMember.objects.filter( member=request.user, - workspace__slug=view.workspace_slug, - role=Admin, + workspace__slug=workspace_slug, + role=ROLE.ADMIN.value, is_active=True, ).exists() @@ -35,20 +46,25 @@ class WorkspaceAdminWriteMemberReadPermission(BasePermission): """ def has_permission(self, request, view): + """Allow active members to read and restrict writes to active admins.""" if request.user.is_anonymous: return False + workspace_slug = get_workspace_slug(view) + if not workspace_slug: + return False + if request.method in SAFE_METHODS: return WorkspaceMember.objects.filter( member=request.user, - workspace__slug=view.workspace_slug, - role__in=[Admin, Member], + workspace__slug=workspace_slug, + role__in=[ROLE.ADMIN.value, ROLE.MEMBER.value], is_active=True, ).exists() return WorkspaceMember.objects.filter( member=request.user, - workspace__slug=view.workspace_slug, - role=Admin, + workspace__slug=workspace_slug, + role=ROLE.ADMIN.value, is_active=True, ).exists() diff --git a/apps/api/plane/app/views/asset/v2.py b/apps/api/plane/app/views/asset/v2.py index 1ee897e4405..98e3e1b1373 100644 --- a/apps/api/plane/app/views/asset/v2.py +++ b/apps/api/plane/app/views/asset/v2.py @@ -837,16 +837,8 @@ def post(self, request, slug, asset_id): return Response({"error": "Project not found"}, status=status.HTTP_404_NOT_FOUND) storage = S3Storage(request=request) -<<<<<<< HEAD - # Scope the source asset to workspaces the caller is an active member of, + # Scope the source asset lookup to workspaces the caller is a member of, # so a known asset UUID cannot be copied out of another tenant. - member_workspace_ids = WorkspaceMember.objects.filter( - member=request.user, is_active=True - ).values_list("workspace_id", flat=True) - original_asset = FileAsset.objects.filter( - id=asset_id, is_uploaded=True, workspace_id__in=member_workspace_ids -======= - # Scope the source asset lookup to workspaces the caller is a member of user_workspace_ids = WorkspaceMember.objects.filter( member=request.user, is_active=True, @@ -855,13 +847,11 @@ def post(self, request, slug, asset_id): id=asset_id, is_uploaded=True, workspace_id__in=user_workspace_ids, ->>>>>>> fd16d033fccb37f18e8794df739c8c24ba9d7939 ).first() if not original_asset: return Response({"error": "Asset not found"}, status=status.HTTP_404_NOT_FOUND) -<<<<<<< HEAD # If the source asset belongs to a project, the caller must be a member of # that project (guards secret-project assets from cross-project duplication). if original_asset.project_id and not ProjectMember.objects.filter( @@ -871,11 +861,8 @@ def post(self, request, slug, asset_id): ).exists(): return Response({"error": "Asset not found"}, status=status.HTTP_404_NOT_FOUND) - destination_key = f"{workspace.id}/{uuid.uuid4().hex}-{original_asset.attributes.get('name')}" -======= sanitized_name = sanitize_filename(original_asset.attributes.get("name")) or "unnamed" destination_key = f"{workspace.id}/{uuid.uuid4().hex}-{sanitized_name}" ->>>>>>> fd16d033fccb37f18e8794df739c8c24ba9d7939 duplicated_asset = FileAsset.objects.create( attributes={ "name": original_asset.attributes.get("name"),