From 8fac324d82c903c8022b99dcd4329f3944e57196 Mon Sep 17 00:00:00 2001 From: Michelle Tran Date: Mon, 27 Apr 2026 12:59:21 -0400 Subject: [PATCH] fix(preprod): Enforce has_project_access on snapshot detail GET and DELETE OrganizationPreprodSnapshotEndpoint resolved the artifact by project__organization_id but never checked whether the requesting member had access to the artifact's project. Add the has_project_access check on both handlers, mirroring the sibling PreprodArtifactEndpoint pattern with a staff bypass. GH-20078 --- .../endpoints/preprod_artifact_snapshot.py | 9 ++- .../test_preprod_artifact_snapshot.py | 58 +++++++++++++++++++ 2 files changed, 66 insertions(+), 1 deletion(-) diff --git a/src/sentry/preprod/api/endpoints/preprod_artifact_snapshot.py b/src/sentry/preprod/api/endpoints/preprod_artifact_snapshot.py index 12d9bbe18e3232..0e217afd245676 100644 --- a/src/sentry/preprod/api/endpoints/preprod_artifact_snapshot.py +++ b/src/sentry/preprod/api/endpoints/preprod_artifact_snapshot.py @@ -19,6 +19,7 @@ OrganizationReleasePermission, ) from sentry.api.bases.project import ProjectEndpoint, ProjectReleasePermission +from sentry.auth.staff import is_active_staff from sentry.models.commitcomparison import CommitComparison from sentry.models.organization import Organization from sentry.models.project import Project @@ -140,6 +141,9 @@ def delete(self, request: Request, organization: Organization, snapshot_id: str) except (PreprodArtifact.DoesNotExist, ValueError): return Response({"detail": "Snapshot not found"}, status=404) + if not is_active_staff(request) and not request.access.has_project_access(artifact.project): + return Response({"detail": "Snapshot not found"}, status=404) + try: artifact.preprodsnapshotmetrics except PreprodSnapshotMetrics.DoesNotExist: @@ -188,12 +192,15 @@ def get(self, request: Request, organization: Organization, snapshot_id: str) -> return Response({"detail": "Feature not enabled"}, status=403) try: - artifact = PreprodArtifact.objects.select_related("commit_comparison").get( + artifact = PreprodArtifact.objects.select_related("commit_comparison", "project").get( id=snapshot_id, project__organization_id=organization.id ) except (PreprodArtifact.DoesNotExist, ValueError): return Response({"detail": "Snapshot not found"}, status=404) + if not is_active_staff(request) and not request.access.has_project_access(artifact.project): + return Response({"detail": "Snapshot not found"}, status=404) + try: snapshot_metrics = artifact.preprodsnapshotmetrics except PreprodSnapshotMetrics.DoesNotExist: diff --git a/tests/sentry/preprod/api/endpoints/test_preprod_artifact_snapshot.py b/tests/sentry/preprod/api/endpoints/test_preprod_artifact_snapshot.py index 07e464c8aea065..1aaa7665ca36ef 100644 --- a/tests/sentry/preprod/api/endpoints/test_preprod_artifact_snapshot.py +++ b/tests/sentry/preprod/api/endpoints/test_preprod_artifact_snapshot.py @@ -581,3 +581,61 @@ def test_get_snapshot_no_metrics(self) -> None: assert response.status_code == 404 assert response.data["detail"] == "Snapshot metrics not found" + + def test_get_snapshot_returns_404_for_member_without_project_access(self) -> None: + self.org.flags.allow_joinleave = False + self.org.save() + artifact, _, _, _, _ = self._create_artifact_with_manifest() + team = self.create_team(organization=self.org) + outsider = self.create_user(is_superuser=False) + self.create_member(user=outsider, organization=self.org, role="member", teams=[team]) + self.login_as(user=outsider) + + url = self._get_detail_url(artifact.id) + with self.feature("organizations:preprod-snapshots"): + response = self.client.get(url) + + assert response.status_code == 404 + + +class ProjectPreprodSnapshotDeleteTest(APITestCase): + def setUp(self) -> None: + super().setUp() + self.login_as(user=self.user) + self.org = self.create_organization(owner=self.user) + self.project = self.create_project(organization=self.org) + + def _delete_url(self, snapshot_id): + return reverse( + "sentry-api-0-project-preprod-snapshots-detail", + args=[self.org.slug, snapshot_id], + ) + + def _create_snapshot_artifact(self): + artifact = PreprodArtifact.objects.create( + project=self.project, + state=PreprodArtifact.ArtifactState.UPLOADED, + app_id="com.example.app", + ) + PreprodSnapshotMetrics.objects.create( + preprod_artifact=artifact, + image_count=0, + extras={"manifest_key": f"{self.org.id}/{self.project.id}/{artifact.id}/manifest.json"}, + ) + return artifact + + def test_delete_returns_404_for_member_without_project_access(self) -> None: + self.org.flags.allow_joinleave = False + self.org.save() + artifact = self._create_snapshot_artifact() + team = self.create_team(organization=self.org) + outsider = self.create_user(is_superuser=False) + self.create_member(user=outsider, organization=self.org, role="member", teams=[team]) + self.login_as(user=outsider) + + url = self._delete_url(artifact.id) + with self.feature("organizations:preprod-snapshots"): + response = self.client.delete(url) + + assert response.status_code == 404 + assert PreprodArtifact.objects.filter(id=artifact.id).exists()