Skip to content

Commit 9d669cf

Browse files
committed
Fix ANY project sneaking in segment creation
1 parent 1dca708 commit 9d669cf

File tree

3 files changed

+45
-4
lines changed

3 files changed

+45
-4
lines changed

api/segments/serializers.py

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from functools import cached_property
12
from typing import Any
23

34
import structlog
@@ -79,6 +80,7 @@ class Meta:
7980

8081

8182
class SegmentSerializer(MetadataSerializerMixin, WritableNestedModelSerializer):
83+
instance: Segment | None
8284
rules = SegmentRuleSerializer(many=True, required=True, allow_empty=False)
8385
metadata = MetadataSerializer(required=False, many=True)
8486

@@ -112,17 +114,30 @@ class Meta:
112114
"rules",
113115
"metadata",
114116
]
117+
read_only_fields = ["project"]
118+
119+
@cached_property
120+
def project(self) -> Project:
121+
"""Resolve the project from the view's URL kwargs"""
122+
if self.instance:
123+
return self.instance.project
124+
try:
125+
pk = int(self.context["view"].kwargs["project_pk"])
126+
except KeyError as error:
127+
raise RuntimeError(
128+
"The serializer context is missing a view with project_pk in its URL."
129+
) from error
130+
return Project.objects.get(pk=pk) # type: ignore[no-any-return]
115131

116132
def validate(self, attrs: dict[str, Any]) -> dict[str, Any]:
117133
attrs = super().validate(attrs)
118-
project = self.instance.project if self.instance else attrs["project"] # type: ignore[union-attr]
119-
organisation = project.organisation
134+
organisation = self.project.organisation
120135

121136
self._validate_required_metadata(
122-
organisation, attrs.get("metadata", []), project=project
137+
organisation, attrs.get("metadata", []), project=self.project
123138
)
124139
self._validate_segment_rules_conditions_limit(attrs["rules"])
125-
self._validate_project_segment_limit(project)
140+
self._validate_project_segment_limit(self.project)
126141
return attrs
127142

128143
def create(self, validated_data: dict[str, Any]): # type: ignore[no-untyped-def]

api/segments/views.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,9 @@ def get_queryset(self): # type: ignore[no-untyped-def]
135135

136136
return queryset
137137

138+
def perform_create(self, serializer: SegmentSerializer) -> None: # type: ignore[override]
139+
serializer.save(project_id=self.kwargs["project_pk"]) # type: ignore[no-untyped-call]
140+
138141
@extend_schema(parameters=[AssociatedFeaturesQuerySerializer])
139142
@action(
140143
detail=True,

api/tests/unit/segments/test_unit_segments_views.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1884,3 +1884,26 @@ def test_create_segment__required_metadata_on_other_project__returns_201(
18841884

18851885
# Then
18861886
assert response.status_code == status.HTTP_201_CREATED
1887+
1888+
1889+
def test_create_segment__body_project_differs_from_url__does_not_create_in_other_project(
1890+
admin_client: APIClient,
1891+
project: Project,
1892+
) -> None:
1893+
# Given
1894+
other_org = Organisation.objects.create(name="Other Org")
1895+
other_project = Project.objects.create(name="Other Project", organisation=other_org)
1896+
1897+
# When
1898+
admin_client.post(
1899+
f"/api/v1/projects/{project.id}/segments/",
1900+
data={
1901+
"name": "a_wild_pokemon",
1902+
"project": other_project.id,
1903+
"rules": [{"type": "ALL", "rules": [], "conditions": []}],
1904+
},
1905+
format="json",
1906+
)
1907+
1908+
# Then
1909+
assert not Segment.objects.filter(project=other_project).exists()

0 commit comments

Comments
 (0)