|
4 | 4 |
|
5 | 5 | import sentry_sdk |
6 | 6 | from rest_framework import status |
7 | | -from rest_framework.exceptions import APIException |
| 7 | +from rest_framework.exceptions import APIException, PermissionDenied |
8 | 8 | from rest_framework.permissions import BasePermission |
9 | 9 | from rest_framework.request import Request |
10 | 10 |
|
11 | | -from sentry.api.bases.organization import OrganizationEndpoint, OrganizationEventPermission |
| 11 | +from sentry import features |
| 12 | +from sentry.api.bases.organization import ( |
| 13 | + OrganizationEndpoint, |
| 14 | + OrganizationEventPermission, |
| 15 | + OrganizationPermission, |
| 16 | +) |
12 | 17 | from sentry.constants import ObjectStatus |
13 | 18 | from sentry.models.project import Project |
14 | 19 | from sentry.preprod.authentication import LaunchpadRpcSignatureAuthentication |
@@ -42,7 +47,15 @@ class ProjectPreprodArtifactPermission(OrganizationEventPermission): |
42 | 47 |
|
43 | 48 |
|
44 | 49 | class PreprodArtifactEndpoint(OrganizationEndpoint): |
45 | | - permission_classes: tuple[type[BasePermission], ...] = (OrganizationEventPermission,) |
| 50 | + # Override permission_classes to skip org-level scope checks in the parent class. |
| 51 | + # We do project-level permission checks in convert_args and return 404 (not 403) |
| 52 | + # for security when users lack project access |
| 53 | + permission_classes: tuple[type[BasePermission], ...] = () |
| 54 | + |
| 55 | + def check_object_permissions(self, request: Request, obj: Any) -> None: |
| 56 | + # Set up request.access but skip org-level scope checks. |
| 57 | + # Project-level permissions are checked in convert_args with 404 (not 403) on denial. |
| 58 | + OrganizationPermission().determine_access(request, obj) |
46 | 59 |
|
47 | 60 | def convert_args( |
48 | 61 | self, |
@@ -79,12 +92,28 @@ def convert_args( |
79 | 92 | if project.status != ObjectStatus.ACTIVE: |
80 | 93 | raise HeadPreprodArtifactResourceDoesNotExist |
81 | 94 |
|
82 | | - # Skip project access check for service-to-service RPC authenticated requests |
| 95 | + # Return 404 (not 403) when feature is disabled to avoid information leakage |
| 96 | + if not features.has( |
| 97 | + "organizations:preprod-frontend-routes", project.organization, actor=request.user |
| 98 | + ): |
| 99 | + raise HeadPreprodArtifactResourceDoesNotExist |
| 100 | + |
| 101 | + # Skip project access check for RPC-authenticated requests |
83 | 102 | is_rpc_authenticated = hasattr(request, "successful_authenticator") and isinstance( |
84 | 103 | request.successful_authenticator, LaunchpadRpcSignatureAuthentication |
85 | 104 | ) |
86 | | - if not is_rpc_authenticated and project not in self.get_projects(request, organization): |
87 | | - raise HeadPreprodArtifactResourceDoesNotExist |
| 105 | + |
| 106 | + if not is_rpc_authenticated: |
| 107 | + # Check project access using has_project_access (respects org roles) not |
| 108 | + # has_project_membership (team-only). Convert PermissionDenied to 404 for security. |
| 109 | + try: |
| 110 | + accessible_projects = self.get_projects( |
| 111 | + request, organization, project_ids={project.id} |
| 112 | + ) |
| 113 | + if project not in accessible_projects: |
| 114 | + raise HeadPreprodArtifactResourceDoesNotExist |
| 115 | + except PermissionDenied: |
| 116 | + raise HeadPreprodArtifactResourceDoesNotExist |
88 | 117 |
|
89 | 118 | # If project_id_or_slug is provided, validate it matches the artifact's project |
90 | 119 | if project_id_or_slug is not None: |
|
0 commit comments