Skip to content
Merged
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
16 changes: 13 additions & 3 deletions src/sentry/api/fields/actor.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,10 @@ class OwnerActorField(ActorField):
"""
ActorField variant for owner assignment that validates team membership.

When assigning a team as owner, validates that the requesting user either:
When assigning a team as owner, validation is skipped if:
- Open Team Membership is enabled for the organization (users can join any team)

Otherwise, validates that the requesting user either:
- Has team:admin scope, OR
- Is a member of the team being assigned, OR
- Is a member of the currently assigned team (can reassign from their team)
Expand Down Expand Up @@ -56,6 +59,13 @@ def to_internal_value(self, data) -> Actor | None:
def _validate_team_assignment(self, actor: Actor) -> None:
from sentry.models.organizationmemberteam import OrganizationMemberTeam

organization = self.context.get("organization")

# If Open Team Membership is enabled, users can assign any team
# since they could join any team anyway
if organization and organization.flags.allow_joinleave:
return

request = self.context.get("request")
# Check for access in context directly for background tasks or on request for API requests
access = self.context.get("access") or getattr(request, "access", None)
Expand All @@ -70,7 +80,7 @@ def _validate_team_assignment(self, actor: Actor) -> None:

# Fail closed
if not user:
raise serializers.ValidationError("You do not have permission to assign this owner")
raise serializers.ValidationError("You can only assign teams you are a member of")

# Check if user is a member of the target team
user_is_target_team_member = OrganizationMemberTeam.objects.filter(
Expand All @@ -93,4 +103,4 @@ def _validate_team_assignment(self, actor: Actor) -> None:
if user_is_current_team_member:
return

raise serializers.ValidationError("You do not have permission to assign this owner")
raise serializers.ValidationError("You can only assign teams you are a member of")
21 changes: 16 additions & 5 deletions src/sentry/api/helpers/group_index/update.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
from sentry.models.groupshare import GroupShare
from sentry.models.groupsubscription import GroupSubscription
from sentry.models.grouptombstone import TOMBSTONE_FIELDS_FROM_GROUP, GroupTombstone
from sentry.models.organization import Organization
from sentry.models.organizationmemberteam import OrganizationMemberTeam
from sentry.models.project import Project
from sentry.models.release import Release, ReleaseStatus, follows_semver_versioning_scheme
Expand Down Expand Up @@ -188,6 +189,8 @@ def update_groups(
if len({p.organization_id for p in projects}) > 1:
return Response({"detail": "All groups must belong to same organization."}, status=400)

organization = projects[0].organization if projects else None

if not groups:
return Response({"detail": "No groups found"}, status=204)

Expand Down Expand Up @@ -258,6 +261,7 @@ def update_groups(
data,
res_type,
request.META.get("HTTP_REFERER", ""),
organization,
)


Expand Down Expand Up @@ -772,6 +776,7 @@ def prepare_response(
data: Mapping[str, Any],
res_type: int | None,
referer: str,
organization: Organization | None = None,
) -> Response:
# XXX (ahmed): hack to get the activities to work properly on issues page. Not sure of
# what performance impact this might have & this possibly should be moved else where
Expand All @@ -788,9 +793,10 @@ def prepare_response(
pass

if "assignedTo" in result:
# Validate reassignment permissions for bulk updates
try:
validate_bulk_reassignment(request, group_list, result["assignedTo"], acting_user)
validate_bulk_reassignment(
request, group_list, result["assignedTo"], acting_user, organization
)
except serializers.ValidationError as e:
return Response(e.detail, status=400)

Expand Down Expand Up @@ -1047,11 +1053,13 @@ def validate_bulk_reassignment(
group_list: Sequence[Group],
assigned_actor: Actor | None,
user: RpcUser | User | None = None,
organization: Organization | None = None,
) -> None:
"""
Validate that the user has permission to assign a team to all groups.

When assigning to a team, a user is permitted if any of the following are true:
- Open Team Membership is enabled for the organization, OR
- They have team:admin scope, OR
- They are a member of the target team, OR
- ALL groups are currently assigned to teams the user is a member of
Expand All @@ -1060,14 +1068,17 @@ def validate_bulk_reassignment(
if assigned_actor is None or not assigned_actor.is_team:
return

if organization and organization.flags.allow_joinleave:
return

access = getattr(request, "access", None)
if access and access.has_scope("team:admin"):
return

user = user or getattr(request, "user", None)
if not user or not getattr(user, "is_authenticated", False):
raise serializers.ValidationError(
{"assignedTo": "You do not have permission to assign this owner"}
{"assignedTo": "You can only assign teams you are a member of"}
)

user_is_target_team_member = OrganizationMemberTeam.objects.filter(
Expand All @@ -1093,7 +1104,7 @@ def validate_bulk_reassignment(
# Some groups are either unassigned or assigned to users (not teams)
# User cannot reassign these to a team they're not a member of
raise serializers.ValidationError(
{"assignedTo": "You do not have permission to assign this owner"}
{"assignedTo": "You can only assign teams you are a member of"}
)

current_team_ids = {
Expand All @@ -1111,7 +1122,7 @@ def validate_bulk_reassignment(
# If any group is assigned to a team the user is not a member of, deny
if current_team_ids - user_team_memberships:
raise serializers.ValidationError(
{"assignedTo": "You do not have permission to assign this owner"}
{"assignedTo": "You can only assign teams you are a member of"}
)


Expand Down
10 changes: 8 additions & 2 deletions tests/sentry/api/endpoints/test_project_rule_details.py
Original file line number Diff line number Diff line change
Expand Up @@ -671,8 +671,10 @@ def test_update_owner_type(self) -> None:
assert rule.owner_user_id == self.user.id

def test_team_owner_not_member(self) -> None:
self.organization.flags.allow_joinleave = False
self.organization.save()

team = self.create_team(organization=self.organization)
# Create a non-privileged member user (without team:admin scope)
member_user = self.create_user()
self.create_member(
user=member_user,
Expand All @@ -697,7 +699,7 @@ def test_team_owner_not_member(self) -> None:
**payload,
)
assert "owner" in response.data
assert str(response.data["owner"][0]) == "You do not have permission to assign this owner"
assert str(response.data["owner"][0]) == "You can only assign teams you are a member of"

def test_team_owner_not_member_with_team_admin_scope(self) -> None:
"""Test that users with team:admin scope can assign a team they're not a member of as the owner"""
Expand Down Expand Up @@ -769,9 +771,13 @@ def test_reassign_owner_from_own_team_to_any_team(self) -> None:

def test_cannot_reassign_owner_from_other_team(self) -> None:
"""Test that a user cannot reassign rule ownership from a team they don't belong to"""
self.organization.flags.allow_joinleave = False
self.organization.save()

other_team = self.create_team(organization=self.organization)

member_team = self.create_team(organization=self.organization)
self.project.add_team(member_team)
member_user = self.create_user()
self.create_member(
user=member_user,
Expand Down
6 changes: 4 additions & 2 deletions tests/sentry/api/endpoints/test_project_rules.py
Original file line number Diff line number Diff line change
Expand Up @@ -544,8 +544,10 @@ def test_team_owner(self) -> None:
assert rule.owner_user_id is None

def test_team_owner_not_member(self) -> None:
self.organization.flags.allow_joinleave = False
self.organization.save()

team = self.create_team(organization=self.organization)
# Create a non-privileged member user (without team:admin scope)
member_user = self.create_user()
self.create_member(
user=member_user,
Expand All @@ -567,7 +569,7 @@ def test_team_owner_not_member(self) -> None:
status_code=status.HTTP_400_BAD_REQUEST,
)
assert "owner" in response.data
assert str(response.data["owner"][0]) == "You do not have permission to assign this owner"
assert str(response.data["owner"][0]) == "You can only assign teams you are a member of"

def test_team_owner_not_member_with_team_admin_scope(self) -> None:
"""Test that users with team:admin scope can assign a team they're not a member of as the owner"""
Expand Down
45 changes: 40 additions & 5 deletions tests/sentry/api/fields/test_actor.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,8 @@ class OwnerActorFieldTest(TestCase):

def setUp(self) -> None:
super().setUp()
# Create a second team that self.user is NOT a member of
self.organization.flags.allow_joinleave = False
self.organization.save()
self.other_team = self.create_team(organization=self.organization, name="other-team")

def test_accepts_user_owner_without_restriction(self) -> None:
Expand Down Expand Up @@ -109,7 +110,7 @@ def test_member_cannot_assign_other_team(self) -> None:
assert serializer.errors == {
"owner": [
ErrorDetail(
string="You do not have permission to assign this owner",
string="You can only assign teams you are a member of",
code="invalid",
)
]
Expand Down Expand Up @@ -140,7 +141,7 @@ def test_no_request_context_denied(self) -> None:
assert serializer.errors == {
"owner": [
ErrorDetail(
string="You do not have permission to assign this owner",
string="You can only assign teams you are a member of",
code="invalid",
)
]
Expand All @@ -161,7 +162,7 @@ class MockRequest:
assert serializer.errors == {
"owner": [
ErrorDetail(
string="You do not have permission to assign this owner",
string="You can only assign teams you are a member of",
code="invalid",
)
]
Expand Down Expand Up @@ -210,7 +211,41 @@ def test_member_cannot_reassign_from_other_team(self) -> None:
assert serializer.errors == {
"owner": [
ErrorDetail(
string="You do not have permission to assign this owner",
string="You can only assign teams you are a member of",
code="invalid",
)
]
}

def test_open_team_membership_allows_any_team_assignment(self) -> None:
"""When Open Team Membership is enabled, users can assign any team."""
self.organization.flags.allow_joinleave = True
self.organization.save()

request = self.make_request(user=self.user)
serializer = DummyOwnerSerializer(
data={"owner": f"team:{self.other_team.id}"},
context={"organization": self.organization, "request": request},
)
assert serializer.is_valid(), serializer.errors
assert serializer.validated_data["owner"].id == self.other_team.id
assert serializer.validated_data["owner"].is_team

def test_open_team_membership_disabled_requires_membership(self) -> None:
"""When Open Team Membership is disabled, membership validation applies."""
self.organization.flags.allow_joinleave = False
self.organization.save()

request = self.make_request(user=self.user)
serializer = DummyOwnerSerializer(
data={"owner": f"team:{self.other_team.id}"},
context={"organization": self.organization, "request": request},
)
assert not serializer.is_valid()
assert serializer.errors == {
"owner": [
ErrorDetail(
string="You can only assign teams you are a member of",
code="invalid",
)
]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1910,6 +1910,9 @@ def test_owner_team_not_member_denied(self) -> None:
Test that members cannot assign a team they are not a member of as owner.
This is a regression test for an IDOR vulnerability.
"""
self.organization.flags.allow_joinleave = False
self.organization.save()

other_team = self.create_team(organization=self.organization, name="other-team")

user_with_team = self.create_user(is_superuser=False)
Expand All @@ -1925,7 +1928,7 @@ def test_owner_team_not_member_denied(self) -> None:
data["owner"] = f"team:{other_team.id}"
with self.feature(["organizations:incidents", "organizations:performance-view"]):
resp = self.get_error_response(self.organization.slug, status_code=400, **data)
assert resp.data == {"owner": ["You do not have permission to assign this owner"]}
assert resp.data == {"owner": ["You can only assign teams you are a member of"]}

def test_owner_team_member_allowed(self) -> None:
"""
Expand Down Expand Up @@ -1967,6 +1970,30 @@ def test_owner_team_admin_can_assign_any_team(self) -> None:
resp = self.get_success_response(self.organization.slug, status_code=201, **data)
assert resp.data["owner"] == f"team:{other_team.id}"

def test_owner_team_open_membership_allows_any_team(self) -> None:
"""
Test that when Open Team Membership is enabled, members can assign any team as owner.
"""
self.organization.flags.allow_joinleave = True
self.organization.save()

other_team = self.create_team(organization=self.organization, name="other-team")

user_with_team = self.create_user(is_superuser=False)
self.create_member(
user=user_with_team,
organization=self.organization,
role="member",
teams=[self.team],
)
self.login_as(user_with_team)

data = deepcopy(self.alert_rule_dict)
data["owner"] = f"team:{other_team.id}"
with self.feature(["organizations:incidents", "organizations:performance-view"]):
resp = self.get_success_response(self.organization.slug, status_code=201, **data)
assert resp.data["owner"] == f"team:{other_team.id}"


@freeze_time()
class AlertRuleCreateEndpointTestCrashRateAlert(AlertRuleIndexBase):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -722,6 +722,9 @@ def test_owner_team_not_member_denied(self) -> None:
Test that members cannot assign a team they are not a member of as owner.
This is a regression test for an IDOR vulnerability.
"""
self.organization.flags.allow_joinleave = False
self.organization.save()

other_team = self.create_team(organization=self.organization, name="other-team")

user_with_team = self.create_user(is_superuser=False)
Expand All @@ -744,7 +747,7 @@ def test_owner_team_not_member_denied(self) -> None:
assert response.data == {
"owner": [
ErrorDetail(
string="You do not have permission to assign this owner",
string="You can only assign teams you are a member of",
code="invalid",
)
]
Expand Down Expand Up @@ -800,6 +803,35 @@ def test_owner_team_admin_can_assign_any_team(self) -> None:
monitor = Monitor.objects.get(slug=response.data["slug"])
assert monitor.owner_team_id == other_team.id

def test_owner_team_open_membership_allows_any_team(self) -> None:
"""
Test that when Open Team Membership is enabled, members can assign any team as owner.
"""
self.organization.flags.allow_joinleave = True
self.organization.save()

other_team = self.create_team(organization=self.organization, name="other-team")

user_with_team = self.create_user(is_superuser=False)
self.create_member(
user=user_with_team,
organization=self.organization,
role="member",
teams=[self.team],
)
self.login_as(user_with_team)

data = {
"project": self.project.slug,
"name": "My Monitor",
"type": "cron_job",
"config": {"schedule_type": "crontab", "schedule": "@daily"},
"owner": f"team:{other_team.id}",
}
response = self.get_success_response(self.organization.slug, **data)
monitor = Monitor.objects.get(slug=response.data["slug"])
assert monitor.owner_team_id == other_team.id


class BulkEditOrganizationMonitorTest(MonitorTestCase):
endpoint = "sentry-api-0-organization-monitor-index"
Expand Down
Loading
Loading