Skip to content
Closed
Show file tree
Hide file tree
Changes from 23 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
ed5c642
test(api): Require notes for readonly mutation scopes
dcramer Apr 15, 2026
bd30311
fix(api): Require write scopes on mutation paths
dcramer Apr 15, 2026
2ecd032
fix(api): narrow write scope mutation changes
dcramer Apr 15, 2026
2f08f09
chore(meta): Add CODEOWNERS coverage for markdownTextArea
dcramer Apr 15, 2026
236d262
ref(replays): Expand explicit replay scopes
dcramer Apr 15, 2026
e8cab3e
fix(replays): require event write for replay deletes
dcramer Apr 15, 2026
654e6e9
ref(api): defer incident mutation scope tightening
dcramer Apr 15, 2026
a56d27b
ref(api): document helper permission intent
dcramer Apr 15, 2026
d511a66
test(api): Add regression coverage for tightened scopes
dcramer Apr 15, 2026
9c46864
test(api): Fix write-scope regression tests
dcramer Apr 15, 2026
4251e3d
fix(api): Align alert serializers with write scopes
dcramer Apr 15, 2026
63536e9
fix(api): Tighten alert mutations and replay delete scope
dcramer Apr 16, 2026
47cec09
fix(api): Unwrap org context in alert mutations
dcramer Apr 16, 2026
7871ad4
fix(api): annotate readonly mutations and alert compat
dcramer Apr 17, 2026
76e97a9
fix(api): Preserve anomaly permission resolution
dcramer Apr 17, 2026
a64e276
fix(api): Preserve detector detail 404 behavior
dcramer Apr 17, 2026
f4a8ba6
fix(api): simplify alert mutation permission cleanup
dcramer Apr 17, 2026
7ac8126
fix(api): Restore write scope compatibility
dcramer Apr 17, 2026
182a529
fix(workflow-engine): Fix workflow type-check regressions
dcramer Apr 17, 2026
1b142d9
fix(api): Document readonly mutation exceptions
dcramer Apr 17, 2026
3b6479f
ref(api): Reuse alerting mutation permissions
dcramer Apr 17, 2026
440ad5a
fix(api): Preserve workflow detail legacy scopes
dcramer Apr 17, 2026
c214378
style(workflow-engine): Remove duplicate detector import
dcramer Apr 20, 2026
6d54948
fix(api): Tighten alert and event mutation scopes
dcramer Apr 27, 2026
42fcd49
docs(api): Mark personal scope cleanup follow-up
dcramer Apr 27, 2026
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
3 changes: 3 additions & 0 deletions src/sentry/api/bases/incident.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ class IncidentPermission(OrganizationPermission):
"PUT": ["org:write", "org:admin", "project:read", "project:write", "project:admin"],
"DELETE": ["org:write", "org:admin", "project:read", "project:write", "project:admin"],
}
readonly_mutation_scope_exceptions = {
"PUT": "Incident updates still allow project-scoped members with access to the incident's projects.",
}


class IncidentEndpoint(OrganizationEndpoint):
Expand Down
146 changes: 130 additions & 16 deletions src/sentry/api/bases/organization.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
from sentry.models.project import Project
from sentry.models.release import Release
from sentry.models.releases.release_project import ReleaseProject
from sentry.models.team import Team
from sentry.organizations.services.organization import (
RpcOrganization,
RpcUserOrganizationContext,
Expand Down Expand Up @@ -162,13 +163,20 @@ class OrganizationIntegrationsLoosePermission(OrganizationPermission):
"PUT": ["org:read", "org:write", "org:admin", "org:integrations"],
"DELETE": ["org:admin", "org:integrations"],
}
readonly_mutation_scope_exceptions = {
"POST": "Legacy integration setup helpers still allow org:read and enforce narrower checks in the view.",
"PUT": "Legacy integration edit helpers still allow org:read and enforce narrower checks in the view.",
}


class OrganizationCodeMappingsBulkPermission(OrganizationPermission):
scope_map = {
"GET": ["org:read", "org:write", "org:admin", "org:integrations", "org:ci"],
"POST": ["org:read", "org:write", "org:admin", "org:integrations", "org:ci"],
}
readonly_mutation_scope_exceptions = {
"POST": "Bulk code mapping suggestions remain available to org members with repository access.",
}


class OrganizationAdminPermission(OrganizationPermission):
Expand Down Expand Up @@ -198,6 +206,10 @@ class OrganizationPinnedSearchPermission(OrganizationPermission):
"PUT": ["org:read", "org:write", "org:admin"],
"DELETE": ["org:read", "org:write", "org:admin"],
}
readonly_mutation_scope_exceptions = {
"PUT": "Pinned search changes only update the requesting member's personal pinned state.",
"DELETE": "Pinned search removal only updates the requesting member's personal pinned state.",
}


class OrganizationSearchPermission(OrganizationPermission):
Expand All @@ -207,36 +219,131 @@ class OrganizationSearchPermission(OrganizationPermission):
"PUT": ["org:read", "org:write", "org:admin"],
"DELETE": ["org:read", "org:write", "org:admin"],
}
readonly_mutation_scope_exceptions = {
"POST": "Members may manage personal saved searches without org:write.",
"PUT": "Members may edit personal saved searches without org:write.",
"DELETE": "Members may delete personal saved searches without org:write.",
}


class OrganizationDataExportPermission(OrganizationPermission):
scope_map = {
"GET": ["event:read", "event:write", "event:admin"],
"POST": ["event:read", "event:write", "event:admin"],
"POST": ["event:write", "event:admin"],
}


class OrganizationAlertRulePermission(OrganizationPermission):
scope_map = {
"GET": ["org:read", "org:write", "org:admin", "alerts:read"],
# grant org:read permission, but raise permission denied if the members aren't allowed
# to create alerts and the user isn't a team admin
"POST": ["org:read", "org:write", "org:admin", "alerts:write"],
"PUT": ["org:write", "org:admin", "alerts:write"],
"DELETE": ["org:write", "org:admin", "alerts:write"],
}
def get_organization_id(
organization: Organization | RpcOrganization | RpcUserOrganizationContext,
) -> int:
if isinstance(organization, RpcUserOrganizationContext):
return organization.organization.id

return organization.id


def _has_any_team_scope(request: Request, scope: str) -> bool:
if not request.access.team_ids_with_membership:
return False

teams = Team.objects.filter(id__in=request.access.team_ids_with_membership)
return any(request.access.has_team_scope(team, scope) for team in teams)

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 scope fallback ignores target project

Medium Severity

_has_any_team_scope returns True whenever the requester has alerts:write on any team they belong to, regardless of whether that team is associated with the targeted resource's project. When get_alert_mutation_projects returns None (e.g. multi-project AlertRule, missing kwargs, or unhandled methods like POST on detail endpoints), has_object_permission falls through to this team-wide check, granting the request even though body-level enforcement may not exist or may not match this codepath.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 42fcd49. Configure here.



# Project-scoped alert authoring should rely on the alert-specific write scope.
ALERT_MUTATION_SCOPES = frozenset({"alerts:write"})
LEGACY_ALERT_MUTATION_PROJECT_SCOPES = ("project:read", "org:write", "alerts:write")

class OrganizationDetectorPermission(OrganizationPermission):

def _has_project_alert_write_access(request: Request, projects: Sequence[Project]) -> bool:
return bool(projects) and all(
request.access.has_any_project_scope(project, ALERT_MUTATION_SCOPES) for project in projects
)


def _has_view_project_scoped_alert_write(
request: Request,
view: APIView,
organization: Organization | RpcOrganization | RpcUserOrganizationContext,
) -> bool | None:
get_projects = getattr(view, "get_alert_mutation_projects", None)
if not callable(get_projects):
return None

projects = get_projects(request, organization)
if projects is None:
return None

return _has_project_alert_write_access(request, projects)


def get_legacy_alert_mutation_scopes(view: APIView, method: str | None) -> tuple[str, ...]:
if method is None:
return ()

legacy_scope_map = getattr(view, "legacy_alert_mutation_scope_map", None)
if legacy_scope_map is None:
return ()

scopes = legacy_scope_map.get(method, ())
return tuple(scopes)


class OrganizationAlertingMutationPermission(OrganizationPermission):
scope_map = {
"GET": ["org:read", "org:write", "org:admin", "alerts:read"],
# grant org:read permission, but raise permission denied if the members aren't allowed
# to create alerts and the user isn't a team admin
"POST": ["org:read", "org:write", "org:admin", "alerts:write"],
"PUT": ["org:read", "org:write", "org:admin", "alerts:write"],
"DELETE": ["org:read", "org:write", "org:admin", "alerts:write"],
"POST": ["alerts:write"],
"PUT": ["alerts:write"],
"DELETE": ["alerts:write"],
Comment thread
cursor[bot] marked this conversation as resolved.
}

def has_permission(self, request: Request, view: APIView) -> bool:
if super().has_permission(request, view):
return True

if not request.auth:
return False

current_scopes = request.auth.get_scopes()
return any(
scope in current_scopes
for scope in get_legacy_alert_mutation_scopes(view, request.method)
)

def has_object_permission(
self,
request: Request,
view: APIView,
organization: Organization | RpcOrganization | RpcUserOrganizationContext,
) -> bool:
if super().has_object_permission(request, view, organization):
return True

if request.method not in {"POST", "PUT", "DELETE"}:
return False

if any(
request.access.has_scope(scope)
for scope in get_legacy_alert_mutation_scopes(view, request.method)
):
return True

project_scoped_access = _has_view_project_scoped_alert_write(request, view, organization)
if project_scoped_access is not None:
return project_scoped_access

return bool(getattr(view, "allow_any_team_alert_write_fallback", False)) and (
_has_any_team_scope(request, "alerts:write")
)


class OrganizationAlertRulePermission(OrganizationAlertingMutationPermission):
pass


class OrganizationDetectorPermission(OrganizationAlertingMutationPermission):
pass


class OrgAuthTokenPermission(OrganizationPermission):
scope_map = {
Expand All @@ -245,6 +352,10 @@ class OrgAuthTokenPermission(OrganizationPermission):
"PUT": ["org:read", "org:write", "org:admin"],
"DELETE": ["org:write", "org:admin"],
}
readonly_mutation_scope_exceptions = {
"POST": "Organization auth token creation is a legacy org:read mutation.",
"PUT": "Organization auth token updates are a legacy org:read mutation.",
}


class OrganizationFlagWebHookSigningSecretPermission(OrganizationPermission):
Expand All @@ -253,6 +364,9 @@ class OrganizationFlagWebHookSigningSecretPermission(OrganizationPermission):
"POST": ["org:read", "org:write", "org:admin"],
"DELETE": ["org:write", "org:admin"],
}
readonly_mutation_scope_exceptions = {
"POST": "Webhook signing secret writes allow org:read but the view restricts updates to creators or org writers.",
}


class ControlSiloOrganizationEndpoint(Endpoint):
Expand Down
3 changes: 3 additions & 0 deletions src/sentry/api/bases/organization_request_change.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@


class OrganizationRequestChangeEndpointPermission(OrganizationPermission):
readonly_mutation_scope_exceptions = {
"POST": "This endpoint only files a request for an organization change; members use it without organization write access.",
}
# just requesting so read permission is enough
scope_map = {
"POST": ["org:read"],
Expand Down
3 changes: 3 additions & 0 deletions src/sentry/api/bases/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,9 @@ class ProjectOwnershipPermission(ProjectPermission):
"PUT": ["project:read", "project:write", "project:admin"],
"DELETE": ["project:admin"],
}
readonly_mutation_scope_exceptions = {
"PUT": "Project ownership rules allow project:read for legacy ownership config edits.",
}


class ProjectEndpoint(Endpoint):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@ def get_request_builder_args(user: User, organization: Organization, platforms:

class OnboardingContinuationPermission(OrganizationPermission):
scope_map = {"POST": ["org:read", "org:write", "org:admin"]}
readonly_mutation_scope_exceptions = {
"POST": "Members may trigger their own onboarding continuation email.",
}


@cell_silo_endpoint
Expand Down
3 changes: 3 additions & 0 deletions src/sentry/api/endpoints/organization_onboarding_tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@

class OnboardingTaskPermission(OrganizationPermission):
scope_map = {"POST": ["org:read"], "GET": ["org:read"]}
readonly_mutation_scope_exceptions = {
"POST": "Members may update their own onboarding task state.",
}


@cell_silo_endpoint
Expand Down
3 changes: 3 additions & 0 deletions src/sentry/api/endpoints/organization_recent_searches.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ class OrganizationRecentSearchPermission(OrganizationPermission):
"GET": ["org:read", "org:write", "org:admin"],
"POST": ["org:read", "org:write", "org:admin"],
}
readonly_mutation_scope_exceptions = {
"POST": "Recent search writes only affect the requesting member's history.",
}


@cell_silo_endpoint
Expand Down
3 changes: 3 additions & 0 deletions src/sentry/api/endpoints/project_repo_path_parsing.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,9 @@ class ProjectRepoPathParsingEndpointLoosePermission(ProjectPermission):
scope_map = {
"POST": ["org:read", "project:write", "project:admin"],
}
readonly_mutation_scope_exceptions = {
"POST": "Repo path parsing validates a suggested mapping and does not persist project state.",
}


@cell_silo_endpoint
Expand Down
3 changes: 3 additions & 0 deletions src/sentry/api/endpoints/prompts_activity.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ class PromptsActivityPermission(OrganizationPermission):
"GET": ["org:read", "org:write", "org:admin"],
"PUT": ["org:read", "org:write", "org:admin"],
}
readonly_mutation_scope_exceptions = {
"PUT": "Prompt activity updates only change the requesting member's prompt state.",
}


@cell_silo_endpoint
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@


class RepositoryTokenRegeneratePermission(OrganizationPermission):
readonly_mutation_scope_exceptions = {
"POST": "Codecov token regeneration preserves read-only token access for now.",
}
scope_map = {
"GET": ["org:read", "org:write", "org:admin"],
"POST": ["org:read", "org:write", "org:admin"],
Expand Down
3 changes: 3 additions & 0 deletions src/sentry/codecov/endpoints/sync_repos/sync_repos.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@


class SyncReposPermission(OrganizationPermission):
readonly_mutation_scope_exceptions = {
"POST": "Codecov sync preserves read-only token access pending integration-scope cleanup.",
}
scope_map = {
"GET": ["org:read", "org:write", "org:admin"],
"POST": ["org:read", "org:write", "org:admin"],
Expand Down
3 changes: 3 additions & 0 deletions src/sentry/conduit/endpoints/organization_conduit_demo.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ class OrganizationConduitDemoPermission(OrganizationPermission):
This is a demo-only feature and doesn't modify organization state.
"""

readonly_mutation_scope_exceptions = {
"POST": "Demo credential generation preserves read-only token access for now.",
}
scope_map = {
"POST": ["org:read", "org:write", "org:admin"],
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@


class MemberInvitePermission(OrganizationPermission):
readonly_mutation_scope_exceptions = {
"POST": "Members can create invite requests on this endpoint even when they cannot send invites directly.",
}
scope_map = {
"GET": ["member:read", "member:write", "member:admin"],
# We will do an additional check to see if a user can invite members. If
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@


class MemberInviteDetailsPermission(OrganizationPermission):
readonly_mutation_scope_exceptions = {
"DELETE": "Invite deletion keeps current member-read token semantics for now.",
}
scope_map = {
"GET": ["member:read", "member:write", "member:admin"],
"PUT": ["member:write", "member:admin"],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@


class InviteRequestPermissions(OrganizationPermission):
readonly_mutation_scope_exceptions = {
"POST": "Invite request creation keeps member-read token access for now.",
}
scope_map = {
"GET": ["member:read", "member:write", "member:admin"],
"POST": ["member:read", "member:write", "member:admin"],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,11 @@ def serialize(


class OrganizationTeamMemberPermission(OrganizationPermission):
readonly_mutation_scope_exceptions = {
"POST": "Team membership writes keep current mixed org/member/team token semantics for now.",
"PUT": "Team membership writes keep current mixed org/member/team token semantics for now.",
"DELETE": "Team membership writes keep current mixed org/member/team token semantics for now.",
}
scope_map = {
"GET": [
"org:read",
Expand Down
3 changes: 3 additions & 0 deletions src/sentry/core/endpoints/organization_member_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,9 @@ class MemberConflictValidationError(serializers.ValidationError):


class RelaxedMemberPermission(OrganizationPermission):
readonly_mutation_scope_exceptions = {
"DELETE": "Member deletion keeps self-service and role-comparison semantics for now.",
}
scope_map = {
"GET": ["member:read", "member:write", "member:admin"],
"POST": ["member:write", "member:admin"],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,9 @@ class OrgProjectPermission(OrganizationPermission):
scope_map = {
"POST": ["project:read", "project:write", "project:admin"],
}
readonly_mutation_scope_exceptions = {
"POST": "Experimental team-plus-project creation remains available to members when org settings allow it.",
}


class AuditData(TypedDict):
Expand Down
3 changes: 3 additions & 0 deletions src/sentry/core/endpoints/project_details.py
Original file line number Diff line number Diff line change
Expand Up @@ -507,6 +507,9 @@ class RelaxedProjectPermission(ProjectPermission):
"PUT": ["project:read", "project:write", "project:admin"],
"DELETE": ["project:admin"],
}
readonly_mutation_scope_exceptions = {
"PUT": "Project detail updates still allow project:read for legacy field-level permission checks.",
}


class RelaxedProjectAndStaffPermission(StaffPermissionMixin, RelaxedProjectPermission):
Expand Down
Loading
Loading