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
13 changes: 11 additions & 2 deletions src/sentry/api/bases/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -128,13 +128,22 @@ class ProjectAlertRulePermission(ProjectPermission):

class ProjectOwnershipPermission(ProjectPermission):
scope_map = {
"GET": ["project:read", "project:write", "project:admin"],
"GET": ["project:codeowners", "project:read"],
"POST": ["project:write", "project:admin"],
"PUT": ["project:read", "project:write", "project:admin"],
"PUT": ["project:codeowners"],
"DELETE": ["project:admin"],
}


class ProjectCodeOwnersPermission(ProjectPermission):
scope_map = {
"GET": ["project:codeowners", "project:read"],
"POST": ["project:codeowners"],
"PUT": ["project:codeowners"],
"DELETE": ["project:codeowners"],
}


class ProjectEndpoint(Endpoint):
permission_classes: tuple[type[BasePermission], ...] = (ProjectPermission,)

Expand Down
3 changes: 2 additions & 1 deletion src/sentry/api/permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
from sentry.auth.system import is_system_auth
from sentry.demo_mode.utils import get_readonly_scopes, is_demo_mode_enabled, is_demo_user
from sentry.hybridcloud.rpc import extract_id_from
from sentry.models.apiscopes import add_scope_hierarchy
from sentry.models.orgauthtoken import is_org_auth_token_auth, update_org_auth_token_last_used
from sentry.organizations.services.organization import (
RpcOrganization,
Expand Down Expand Up @@ -120,7 +121,7 @@ def has_permission(self, request: Request, view: APIView) -> bool:

assert request.method is not None
allowed_scopes = set(self.scope_map.get(request.method, []))
current_scopes = request.auth.get_scopes()
current_scopes = add_scope_hierarchy(request.auth.get_scopes())
return any(s in allowed_scopes for s in current_scopes)

def has_object_permission(self, request: Request, view: APIView, obj: Any) -> bool:
Expand Down
4 changes: 3 additions & 1 deletion src/sentry/auth/access.py
Original file line number Diff line number Diff line change
Expand Up @@ -590,7 +590,9 @@ def has_any_project_scope(self, project: Project, scopes: Collection[str]) -> bo

def _wrap_scopes(scopes_upper_bound: Iterable[str] | None) -> frozenset[str] | None:
if scopes_upper_bound is not None:
return frozenset(scopes_upper_bound)
from sentry.models.apiscopes import add_scope_hierarchy

return frozenset(add_scope_hierarchy(list(scopes_upper_bound)))
return None


Expand Down
27 changes: 25 additions & 2 deletions src/sentry/conf/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -1830,6 +1830,8 @@ def custom_parameter_sort(parameter: dict) -> tuple[str, int]:
"project:read",
"project:write",
"project:admin",
"project:create",
"project:codeowners",
"project:releases",
"project:distribution",
"event:read",
Expand Down Expand Up @@ -1867,8 +1869,21 @@ def custom_parameter_sort(parameter: dict) -> tuple[str, int]:
"team:write": {"team:read", "team:write"},
"team:admin": {"team:read", "team:write", "team:admin"},
"project:read": {"project:read"},
"project:write": {"project:read", "project:write"},
"project:admin": {"project:read", "project:write", "project:admin"},
"project:write": {
"project:read",
"project:write",
"project:create",
"project:codeowners",
},
"project:admin": {
"project:read",
"project:write",
"project:admin",
"project:create",
"project:codeowners",
},
"project:create": {"project:create"},
"project:codeowners": {"project:codeowners"},
"project:releases": {"project:releases"},
"project:distribution": {"project:distribution"},
"event:read": {"event:read"},
Expand Down Expand Up @@ -1916,7 +1931,9 @@ def custom_parameter_sort(parameter: dict) -> tuple[str, int]:
(
("project:admin", "Read, write, and admin access to projects."),
("project:write", "Read and write access to projects."),
("project:codeowners", "Manage code owner and ownership rules."),
("project:read", "Read access to projects."),
("project:create", "Create projects."),
),
(("project:releases", "Read, write, and admin access to project releases."),),
(("project:distribution", "Access to app distribution and preprod artifacts."),),
Expand Down Expand Up @@ -1957,6 +1974,8 @@ def custom_parameter_sort(parameter: dict) -> tuple[str, int]:
"event:write",
"event:admin",
"project:releases",
"project:create",
"project:codeowners",
"project:read",
"org:read",
"member:invite",
Expand Down Expand Up @@ -1989,6 +2008,7 @@ def custom_parameter_sort(parameter: dict) -> tuple[str, int]:
"project:read",
"project:write",
"project:admin",
"project:codeowners",
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.

Missing project:create scope breaks non-member role project creation

High Severity

The admin (retired), manager, and owner org roles all have project:write/project:admin but lack explicit project:create in their scopes. The member role is the only one with project:create. Since add_scope_hierarchy is only applied to token scopes and scopes_upper_bound, but NOT to member role scopes (via RpcBackedAccess.scopes), these roles lose the ability to create projects. For session auth, scopes_upper_bound is None so member scopes are used directly. For token auth, the intersection of unexpanded member scopes with the expanded upper bound still excludes project:create. Both TeamProjectPermission and OrgProjectPermission now require project:create for POST, so admins/managers/owners get 403s.

Additional Locations (2)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 70087ed. Configure here.

"project:releases",
"team:read",
"team:write",
Expand All @@ -2014,6 +2034,7 @@ def custom_parameter_sort(parameter: dict) -> tuple[str, int]:
"project:read",
"project:write",
"project:admin",
"project:codeowners",
"project:releases",
"team:read",
"team:write",
Expand Down Expand Up @@ -2051,6 +2072,7 @@ def custom_parameter_sort(parameter: dict) -> tuple[str, int]:
"project:read",
"project:write",
"project:admin",
"project:codeowners",
"project:releases",
"event:read",
"event:write",
Expand Down Expand Up @@ -2097,6 +2119,7 @@ def custom_parameter_sort(parameter: dict) -> tuple[str, int]:
"org:read",
"member:read",
"project:read",
"project:codeowners",
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.

Team admin role also missing project:create scope

Medium Severity

The team admin role in SENTRY_TEAM_ROLES has project:write and project:admin but lacks project:create. When has_team_scope is checked as a fallback in the permission chain, team-level scopes also won't contain project:create, closing off the last path that could grant project creation access to users whose org-level check already failed.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 70087ed. Configure here.

"project:write",
"project:admin",
"project:releases",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,13 +47,9 @@ def fetch_slugifed_email_username(email: str) -> str:
return slugify(Address(addr_spec=email).username)


# This endpoint is intended to be available to all members of an
# organization so we include "project:read" in the POST scopes.


class OrgProjectPermission(OrganizationPermission):
scope_map = {
"POST": ["project:read", "project:write", "project:admin"],
"POST": ["project:create"],
}


Expand Down
8 changes: 7 additions & 1 deletion src/sentry/core/endpoints/team_projects.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,11 +118,17 @@ def validate_name(self, value: str) -> str:
class TeamProjectPermission(TeamPermission):
scope_map = {
"GET": ["project:read", "project:write", "project:admin"],
"POST": ["project:write", "project:admin"],
"POST": ["project:create"],
"PUT": ["project:write", "project:admin"],
"DELETE": ["project:admin"],
}

def has_object_permission(self, request: Request, view, team) -> bool:
if request.method == "POST" and request.access.has_scope("project:create"):
return request.access.has_team_access(team)

return super().has_object_permission(request, view, team)
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.

Early return check uses uninitialized access object

Medium Severity

The has_object_permission override checks request.access.has_scope("project:create") before determine_access has been called. At this point request.access is the default NoAccess() set during request initialization, so has_scope always returns False and this early-return path is unreachable dead code. The determine_access call that properly initializes request.access only happens inside OrganizationPermission.has_object_permission, which is reached via the super() fallthrough.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 70087ed. Configure here.



class AuditData(TypedDict):
request: Request
Expand Down
4 changes: 3 additions & 1 deletion src/sentry/issues/endpoints/bases/codeowners.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
from rest_framework.request import Request

from sentry import features
from sentry.api.bases.project import ProjectEndpoint
from sentry.api.bases.project import ProjectCodeOwnersPermission, ProjectEndpoint
from sentry.models.project import Project
from sentry.utils import metrics


class ProjectCodeOwnersBase(ProjectEndpoint):
permission_classes = (ProjectCodeOwnersPermission,)

def has_feature(self, request: Request, project: Project) -> bool:
return bool(
features.has(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
# Generated by Django 5.2.12 on 2026-04-15 00:00

from django.db import migrations

import bitfield.models
from sentry.new_migrations.migrations import CheckedMigration


class Migration(CheckedMigration):
is_post_deployment = False

dependencies = [
("sentry", "1062_backfill_eventattachment_date_expires"),
]

operations = [
migrations.AlterField(
model_name="apiauthorization",
name="scopes",
field=bitfield.models.BitField(
[
"project:read",
"project:write",
"project:admin",
"project:releases",
"team:read",
"team:write",
"team:admin",
"event:read",
"event:write",
"event:admin",
"org:read",
"org:write",
"org:admin",
"member:read",
"member:write",
"member:admin",
"org:integrations",
"alerts:read",
"alerts:write",
"member:invite",
"project:distribution",
"project:create",
"project:codeowners",
],
default=None,
),
),
migrations.AlterField(
model_name="apikey",
name="scopes",
field=bitfield.models.BitField(
[
"project:read",
"project:write",
"project:admin",
"project:releases",
"team:read",
"team:write",
"team:admin",
"event:read",
"event:write",
"event:admin",
"org:read",
"org:write",
"org:admin",
"member:read",
"member:write",
"member:admin",
"org:integrations",
"alerts:read",
"alerts:write",
"member:invite",
"project:distribution",
"project:create",
"project:codeowners",
],
default=None,
),
),
migrations.AlterField(
model_name="apitoken",
name="scopes",
field=bitfield.models.BitField(
[
"project:read",
"project:write",
"project:admin",
"project:releases",
"team:read",
"team:write",
"team:admin",
"event:read",
"event:write",
"event:admin",
"org:read",
"org:write",
"org:admin",
"member:read",
"member:write",
"member:admin",
"org:integrations",
"alerts:read",
"alerts:write",
"member:invite",
"project:distribution",
"project:create",
"project:codeowners",
],
default=None,
),
),
migrations.AlterField(
model_name="sentryapp",
name="scopes",
field=bitfield.models.BitField(
[
"project:read",
"project:write",
"project:admin",
"project:releases",
"team:read",
"team:write",
"team:admin",
"event:read",
"event:write",
"event:admin",
"org:read",
"org:write",
"org:admin",
"member:read",
"member:write",
"member:admin",
"org:integrations",
"alerts:read",
"alerts:write",
"member:invite",
"project:distribution",
"project:create",
"project:codeowners",
],
default=None,
),
),
]
17 changes: 16 additions & 1 deletion src/sentry/models/apiscopes.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ def add_scope_hierarchy(curr_scopes: Sequence[str]) -> list[str]:


class ApiScopes(Sequence):
# Append new scopes to the end of the overall bitfield ordering. Legacy
# tokens can still fall back to the bitfield when `scope_list` is empty.
project = (
("project:read"),
("project:write"),
Expand All @@ -33,12 +35,22 @@ class ApiScopes(Sequence):

event = (("event:read"), ("event:write"), ("event:admin"))

org = (("org:read"), ("org:write"), ("org:integrations"), ("org:admin"))
org = (
("org:read"),
("org:write"),
("org:integrations"),
("org:admin"),
)

member = (("member:read"), ("member:write"), ("member:admin"), ("member:invite"))

alerts = (("alerts:read"), ("alerts:write"))

appended = (
("project:create"),
("project:codeowners"),
)

def __init__(self):
self.scopes = (
self.__class__.project
Expand All @@ -47,6 +59,7 @@ def __init__(self):
+ self.__class__.org
+ self.__class__.member
+ self.__class__.alerts
+ self.__class__.appended
)

def __getitem__(self, value):
Expand Down Expand Up @@ -92,6 +105,8 @@ class Meta:
"alerts:write": bool,
"member:invite": bool,
"project:distribution": bool,
"project:create": bool,
"project:codeowners": bool,
},
)
assert set(ScopesDict.__annotations__) == set(ApiScopes())
Expand Down
2 changes: 2 additions & 0 deletions src/sentry/models/organization.py
Original file line number Diff line number Diff line change
Expand Up @@ -537,6 +537,8 @@ def get_scopes(self, role: Role) -> frozenset[str]:
scopes.discard("event:admin")
if not self.get_option("sentry:alerts_member_write", ALERTS_MEMBER_WRITE_DEFAULT):
scopes.discard("alerts:write")
if role.id == "member" and self.flags.disable_member_project_creation:
scopes.discard("project:create")
return frozenset(scopes)

def get_option(
Expand Down
Loading
Loading