Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 17 additions & 8 deletions src/sentry/api/bases/organization.py
Original file line number Diff line number Diff line change
Expand Up @@ -196,17 +196,26 @@ class OrganizationUserReportsPermission(OrganizationPermission):

class OrganizationPinnedSearchPermission(OrganizationPermission):
scope_map = {
"PUT": ["org:read", "org:write", "org:admin"],
"DELETE": ["org:read", "org:write", "org:admin"],
"PUT": ["user:preferences"],
"DELETE": ["user:preferences"],
}


class OrganizationSearchPermission(OrganizationPermission):
scope_map = {
"GET": ["org:read", "org:write", "org:admin"],
"POST": ["org:read", "org:write", "org:admin"],
"PUT": ["org:read", "org:write", "org:admin"],
"DELETE": ["org:read", "org:write", "org:admin"],
"GET": ["org:read", "org:searches"],
"POST": ["org:searches"],
"PUT": ["org:searches"],
"DELETE": ["org:searches"],
}


class OrganizationPreferencePermission(OrganizationPermission):
scope_map = {
"GET": ["user:preferences"],
"POST": ["user:preferences"],
"PUT": ["user:preferences"],
"DELETE": ["user:preferences"],
}


Expand Down Expand Up @@ -281,8 +290,8 @@ class OrgAuthTokenPermission(OrganizationPermission):
class OrganizationFlagWebHookSigningSecretPermission(OrganizationPermission):
scope_map = {
"GET": ["org:read", "org:write", "org:admin"],
"POST": ["org:read", "org:write", "org:admin"],
"DELETE": ["org:write", "org:admin"],
"POST": ["flags:write"],
"DELETE": ["flags:write"],
}


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from sentry.api.api_owners import ApiOwner
from sentry.api.api_publish_status import ApiPublishStatus
from sentry.api.base import cell_silo_endpoint
from sentry.api.bases.organization import OrganizationEndpoint, OrganizationPermission
from sentry.api.bases.organization import OrganizationEndpoint, OrganizationPreferencePermission
from sentry.api.serializers.rest_framework.base import CamelSnakeSerializer
from sentry.models.organization import Organization
from sentry.users.models.user import User
Expand Down Expand Up @@ -41,17 +41,13 @@ def get_request_builder_args(user: User, organization: Organization, platforms:
}


class OnboardingContinuationPermission(OrganizationPermission):
scope_map = {"POST": ["org:read", "org:write", "org:admin"]}


@cell_silo_endpoint
class OrganizationOnboardingContinuationEmail(OrganizationEndpoint):
publish_status = {
"POST": ApiPublishStatus.PRIVATE,
}
owner = ApiOwner.VALUE_DISCOVERY
permission_classes = (OnboardingContinuationPermission,)
permission_classes = (OrganizationPreferencePermission,)

def post(self, request: Request, organization: Organization):
serializer = OnboardingContinuationSerializer(data=request.data)
Expand Down
8 changes: 2 additions & 6 deletions src/sentry/api/endpoints/organization_onboarding_tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,24 +7,20 @@
from sentry.api.api_owners import ApiOwner
from sentry.api.api_publish_status import ApiPublishStatus
from sentry.api.base import cell_silo_endpoint
from sentry.api.bases.organization import OrganizationEndpoint, OrganizationPermission
from sentry.api.bases.organization import OrganizationEndpoint, OrganizationPreferencePermission
from sentry.api.serializers import serialize
from sentry.models.organization import Organization
from sentry.models.organizationonboardingtask import OnboardingTask, OnboardingTaskStatus


class OnboardingTaskPermission(OrganizationPermission):
scope_map = {"POST": ["org:read"], "GET": ["org:read"]}


@cell_silo_endpoint
class OrganizationOnboardingTaskEndpoint(OrganizationEndpoint):
publish_status = {
"POST": ApiPublishStatus.PRIVATE,
"GET": ApiPublishStatus.PRIVATE,
}
owner = ApiOwner.VALUE_DISCOVERY
permission_classes = (OnboardingTaskPermission,)
permission_classes = (OrganizationPreferencePermission,)

def post(self, request: Request, organization) -> Response:
task_id = onboarding_tasks.get_task_lookup_by_key(request.data["task"])
Expand Down
4 changes: 2 additions & 2 deletions src/sentry/api/endpoints/organization_recent_searches.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@ class RecentSearchSerializer(serializers.Serializer):

class OrganizationRecentSearchPermission(OrganizationPermission):
scope_map = {
"GET": ["org:read", "org:write", "org:admin"],
"POST": ["org:read", "org:write", "org:admin"],
"GET": ["user:preferences"],
"POST": ["user:preferences"],
}


Expand Down
38 changes: 36 additions & 2 deletions src/sentry/conf/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -1817,7 +1817,9 @@ def custom_parameter_sort(parameter: dict) -> tuple[str, int]:
"org:write",
"org:admin",
"org:integrations",
"org:searches",
"org:ci",
"user:preferences",
# "org:superuser", Do not use for any type of superuser permission/access checks
# Assigned to active SU sessions in src/sentry/auth/access.py to enable UI elements
"member:invite",
Expand All @@ -1839,6 +1841,7 @@ def custom_parameter_sort(parameter: dict) -> tuple[str, int]:
"event:admin",
"alerts:read",
"alerts:write",
"flags:write",
# openid, profile, and email aren't prefixed to maintain compliance with the OIDC spec.
# https://auth0.com/docs/get-started/apis/scopes/openid-connect-scopes.
"openid",
Expand All @@ -1857,10 +1860,19 @@ def custom_parameter_sort(parameter: dict) -> tuple[str, int]:

SENTRY_SCOPE_HIERARCHY_MAPPING = {
"org:read": {"org:read"},
"org:write": {"org:read", "org:write"},
"org:admin": {"org:read", "org:write", "org:admin", "org:integrations"},
"org:write": {"org:read", "org:write", "org:searches", "flags:write"},
"org:admin": {
"org:read",
"org:write",
"org:admin",
"org:integrations",
"org:searches",
"flags:write",
},
"org:integrations": {"org:integrations"},
"org:searches": {"org:searches"},
"org:ci": {"org:ci"},
"user:preferences": {"user:preferences"},
"member:invite": {"member:read", "member:invite"},
"member:read": {"member:read"},
"member:write": {"member:read", "member:invite", "member:write"},
Expand Down Expand Up @@ -1891,6 +1903,7 @@ def custom_parameter_sort(parameter: dict) -> tuple[str, int]:
"event:admin": {"event:read", "event:write", "event:admin"},
"alerts:read": {"alerts:read"},
"alerts:write": {"alerts:read", "alerts:write"},
"flags:write": {"flags:write"},
"openid": {"openid"},
"profile": {"profile"},
"email": {"email"},
Expand All @@ -1905,6 +1918,15 @@ def custom_parameter_sort(parameter: dict) -> tuple[str, int]:
]
)

# Scopes that are valid for first-party session flows and direct tokens, but
# should not be grantable to third-party API applications or Sentry Apps.
SENTRY_NON_APP_GRANTABLE_SCOPES = frozenset(
[
"flags:write",
"user:preferences",
]
)

SENTRY_SCOPE_SETS = (
(
("org:admin", "Read, write, and admin access to organization details."),
Expand All @@ -1917,6 +1939,8 @@ def custom_parameter_sort(parameter: dict) -> tuple[str, int]:
"Read, write, and admin access to organization integrations.",
),
),
(("user:preferences", "Manage personal preferences and user-owned state."),),
(("org:searches", "Manage saved searches, saved queries, and custom views."),),
(
("member:admin", "Read, write, and admin access to organization members."),
("member:write", "Read and write access to organization members."),
Expand Down Expand Up @@ -1946,6 +1970,7 @@ def custom_parameter_sort(parameter: dict) -> tuple[str, int]:
("alerts:write", "Read and write alerts"),
("alerts:read", "Read alerts"),
),
(("flags:write", "Manage feature flag webhook signing secrets."),),
(("openid", "Confirms authentication status and provides basic information."),),
(
(
Expand Down Expand Up @@ -1973,6 +1998,9 @@ def custom_parameter_sort(parameter: dict) -> tuple[str, int]:
"event:read",
"event:write",
"event:admin",
"flags:write",
"org:searches",
"user:preferences",
"project:releases",
"project:create",
"project:codeowners",
Expand Down Expand Up @@ -2002,6 +2030,8 @@ def custom_parameter_sort(parameter: dict) -> tuple[str, int]:
"event:read",
"event:write",
"event:admin",
"org:searches",
"user:preferences",
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Member role has flags:write but admin role does not

Medium Severity

The member role includes flags:write directly, but the (retired) admin role does not, and the admin role lacks any scope (like org:write) that would grant flags:write through the hierarchy. This creates a privilege inversion where a lower-privileged member can manage flag webhook signing secrets but a higher-privileged admin cannot. The admin role's scopes need flags:write added for consistency.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 7cc0772. Configure here.

"org:read",
"member:read",
"member:invite",
Expand All @@ -2027,6 +2057,8 @@ def custom_parameter_sort(parameter: dict) -> tuple[str, int]:
"event:read",
"event:write",
"event:admin",
"org:searches",
"user:preferences",
"member:invite",
"member:read",
"member:write",
Expand Down Expand Up @@ -2062,6 +2094,8 @@ def custom_parameter_sort(parameter: dict) -> tuple[str, int]:
"org:write",
"org:admin",
"org:integrations",
"org:searches",
"user:preferences",
"member:invite",
"member:read",
"member:write",
Expand Down
91 changes: 45 additions & 46 deletions src/sentry/core/endpoints/project_details.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,32 +96,14 @@ def validate(self, data):
return data


class ProjectMemberSerializer(serializers.Serializer):
PROJECT_PREFERENCE_FIELDS = frozenset({"isBookmarked"})


class ProjectPreferenceSerializer(serializers.Serializer):
isBookmarked = serializers.BooleanField(
help_text="Enables starring the project within the projects tab. Can be updated with **`project:read`** permission.",
help_text="Enables starring the project within the projects tab. Can be updated with **`user:preferences`** permission.",
required=False,
)
autofixAutomationTuning = serializers.ChoiceField(
choices=[item.value for item in AutofixAutomationTuningSettings],
required=False,
)
seerScannerAutomation = serializers.BooleanField(required=False)
preprodSizeStatusChecksEnabled = serializers.BooleanField(
help_text="Enable preprod size status checks. Can be updated with **`project:read`** permission.",
required=False,
)
preprodSizeStatusChecksRules = serializers.JSONField(required=False)
preprodSnapshotStatusChecksEnabled = serializers.BooleanField(required=False)
preprodSnapshotStatusChecksFailOnAdded = serializers.BooleanField(required=False)
preprodSnapshotStatusChecksFailOnRemoved = serializers.BooleanField(required=False)
preprodSizeEnabledByCustomer = serializers.BooleanField(required=False, allow_null=True)
preprodDistributionEnabledByCustomer = serializers.BooleanField(required=False, allow_null=True)
preprodDistributionPrCommentsEnabledByCustomer = serializers.BooleanField(
required=False, allow_null=True
)
preprodSnapshotPrCommentsEnabled = serializers.BooleanField(required=False, allow_null=True)
preprodSizeEnabledQuery = serializers.CharField(required=False, allow_null=True)
preprodDistributionEnabledQuery = serializers.CharField(required=False, allow_null=True)


@extend_schema_serializer(
Expand Down Expand Up @@ -171,7 +153,29 @@ class ProjectMemberSerializer(serializers.Serializer):
"preprodSnapshotPrCommentsEnabled",
]
)
class ProjectAdminSerializer(ProjectMemberSerializer):
class ProjectAdminSerializer(ProjectPreferenceSerializer):
autofixAutomationTuning = serializers.ChoiceField(
choices=[item.value for item in AutofixAutomationTuningSettings],
required=False,
)
seerScannerAutomation = serializers.BooleanField(required=False)
preprodSizeStatusChecksEnabled = serializers.BooleanField(
help_text="Enable preprod size status checks. Can be updated with **`project:write`** permission.",
required=False,
)
preprodSizeStatusChecksRules = serializers.JSONField(required=False)
preprodSnapshotStatusChecksEnabled = serializers.BooleanField(required=False)
preprodSnapshotStatusChecksFailOnAdded = serializers.BooleanField(required=False)
preprodSnapshotStatusChecksFailOnRemoved = serializers.BooleanField(required=False)
preprodSizeEnabledByCustomer = serializers.BooleanField(required=False, allow_null=True)
preprodDistributionEnabledByCustomer = serializers.BooleanField(required=False, allow_null=True)
preprodDistributionPrCommentsEnabledByCustomer = serializers.BooleanField(
required=False, allow_null=True
)
preprodSnapshotPrCommentsEnabled = serializers.BooleanField(required=False, allow_null=True)
preprodSizeEnabledQuery = serializers.CharField(required=False, allow_null=True)
preprodDistributionEnabledQuery = serializers.CharField(required=False, allow_null=True)

name = serializers.CharField(
help_text="The name for the project",
max_length=200,
Expand Down Expand Up @@ -501,10 +505,10 @@ def validate_debugFilesRole(self, value):

class RelaxedProjectPermission(ProjectPermission):
scope_map = {
"GET": ["project:read", "project:write", "project:admin"],
"POST": ["project:write", "project:admin"],
"GET": ["project:read"],
"POST": ["project:write"],
# PUT checks for permissions based on fields
"PUT": ["project:read", "project:write", "project:admin"],
"PUT": ["project:write", "project:admin", "user:preferences"],
"DELETE": ["project:admin"],
}

Expand Down Expand Up @@ -595,27 +599,30 @@ def put(self, request: Request, project) -> Response:
"""
Update various attributes and configurable settings for the given project.

Note that solely having the **`project:read`** scope restricts updatable settings to
`isBookmarked`, `autofixAutomationTuning`, `seerScannerAutomation`,
`preprodSizeStatusChecksEnabled`, `preprodSizeStatusChecksRules`,
`preprodSizeEnabledQuery`, `preprodDistributionEnabledQuery`,
`preprodSizeEnabledByCustomer`, `preprodDistributionEnabledByCustomer`,
and `preprodDistributionPrCommentsEnabledByCustomer`.
Note that the **`user:preferences`** scope only allows updating
`isBookmarked`. All project-wide settings require **`project:write`**.
"""
if not request.user.is_authenticated:
return Response(status=status.HTTP_400_BAD_REQUEST)

old_data = serialize(project, request.user, DetailedProjectSerializer())
has_elevated_scopes = request.access and (
has_project_write_scope = request.access and (
request.access.has_scope("project:write")
or request.access.has_scope("project:admin")
or request.access.has_any_project_scope(project, ["project:write", "project:admin"])
)
requested_fields = set(request.data.keys())

if has_elevated_scopes:
serializer_cls: type[ProjectMemberSerializer] = ProjectAdminSerializer
if not has_project_write_scope and not requested_fields.issubset(PROJECT_PREFERENCE_FIELDS):
return Response(
{"detail": "You do not have permission to perform this action."},
status=403,
)

if has_project_write_scope:
serializer_cls: type[ProjectPreferenceSerializer] = ProjectAdminSerializer
else:
serializer_cls = ProjectMemberSerializer
serializer_cls = ProjectPreferenceSerializer

serializer = serializer_cls(
data=request.data, partial=True, context={"project": project, "request": request}
Expand All @@ -631,14 +638,6 @@ def put(self, request: Request, project) -> Response:
)
if not serializer.is_valid():
return Response(serializer.errors, status=400)

if not has_elevated_scopes:
for key in ProjectAdminSerializer().fields.keys():
if request.data.get(key) and not result.get(key):
return Response(
{"detail": "You do not have permission to perform this action."},
status=403,
)
changed = False
changed_proj_settings = {}

Expand Down Expand Up @@ -907,7 +906,7 @@ def put(self, request: Request, project) -> Response:
if project.update_option("sentry:debug_files_role", result["debugFilesRole"]):
changed_proj_settings["sentry:debug_files_role"] = result["debugFilesRole"]

if has_elevated_scopes:
if has_project_write_scope:
options = result.get("options", {})
if "sentry:origins" in options:
project.update_option(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,8 @@

class MemberPermission(OrganizationPermission):
scope_map = {
"GET": ["member:read", "member:write"],
"PUT": ["member:read", "member:write"],
"GET": ["user:preferences"],
"PUT": ["user:preferences"],
}


Expand Down
Loading
Loading