Skip to content

Commit e8ce5ab

Browse files
gagantrivedipre-commit-ci[bot]Zaimwa9
authored
feat: enforce feature ownership project setting (#7067)
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: wadii <wadii.zaim@flagsmith.com>
1 parent de6e4e2 commit e8ce5ab

File tree

25 files changed

+987
-239
lines changed

25 files changed

+987
-239
lines changed

api/features/serializers.py

Lines changed: 75 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
CreateSegmentOverrideFeatureStateSerializer,
1111
FeatureStateValueSerializer,
1212
)
13+
from common.projects.permissions import VIEW_PROJECT
14+
from django.db import models
1315
from drf_spectacular.utils import extend_schema_field
1416
from drf_writable_nested import ( # type: ignore[attr-defined]
1517
WritableNestedModelSerializer,
@@ -29,6 +31,7 @@
2931
FeatureFlagCodeReferencesRepositoryCountSerializer,
3032
)
3133
from projects.models import Project
34+
from users.models import FFAdminUser, UserPermissionGroup
3235
from users.serializers import (
3336
UserIdsSerializer,
3437
UserListSerializer,
@@ -161,12 +164,28 @@ def validate_tags(self, tags: str) -> list[int]:
161164
raise serializers.ValidationError("Tag IDs must be integers.")
162165

163166

167+
class _FeatureOwnersField(serializers.PrimaryKeyRelatedField[FFAdminUser]):
168+
def get_queryset(self) -> models.QuerySet[FFAdminUser]:
169+
return FFAdminUser.objects.all()
170+
171+
def to_representation(self, value: FFAdminUser) -> dict[str, Any]:
172+
return UserListSerializer(value).data
173+
174+
175+
class _FeatureGroupOwnersField(serializers.PrimaryKeyRelatedField[UserPermissionGroup]):
176+
def get_queryset(self) -> models.QuerySet[UserPermissionGroup]:
177+
return UserPermissionGroup.objects.all()
178+
179+
def to_representation(self, value: UserPermissionGroup) -> dict[str, Any]:
180+
return UserPermissionGroupSummarySerializer(value).data
181+
182+
164183
class CreateFeatureSerializer(DeleteBeforeUpdateWritableNestedModelSerializer):
165184
multivariate_options = NestedMultivariateFeatureOptionSerializer(
166185
many=True, required=False
167186
)
168-
owners = UserListSerializer(many=True, read_only=True)
169-
group_owners = UserPermissionGroupSummarySerializer(many=True, read_only=True)
187+
owners = _FeatureOwnersField(many=True, required=False)
188+
group_owners = _FeatureGroupOwnersField(many=True, required=False)
170189

171190
environment_feature_state = serializers.SerializerMethodField()
172191
segment_feature_state = serializers.SerializerMethodField()
@@ -240,12 +259,22 @@ def create(self, validated_data: dict) -> Feature: # type: ignore[type-arg]
240259
project = self.context["project"]
241260
self.validate_project_features_limit(project)
242261

243-
# Add the default(User creating the feature) owner of the feature
244-
# NOTE: pop the user before passing the data to create
262+
# Pop M2M fields before creating the instance (can't pass to Model.objects.create)
263+
owners: list[FFAdminUser] = validated_data.pop("owners", [])
264+
group_owners: list[UserPermissionGroup] = validated_data.pop("group_owners", [])
265+
245266
user = validated_data.pop("user", None)
246267
instance = super(CreateFeatureSerializer, self).create(validated_data) # type: ignore[no-untyped-call]
247-
if user and getattr(user, "is_master_api_key_user", False) is False:
268+
269+
if owners:
270+
instance.owners.add(*owners)
271+
elif user and getattr(user, "is_master_api_key_user", False) is False:
272+
# Auto-add the creating user as owner only when no explicit owners provided
248273
instance.owners.add(user)
274+
275+
if group_owners:
276+
instance.group_owners.add(*group_owners)
277+
249278
return instance # type: ignore[no-any-return]
250279

251280
def validate_project_features_limit(self, project: Project) -> None:
@@ -275,6 +304,26 @@ def validate_multivariate_options(self, multivariate_options): # type: ignore[n
275304
raise serializers.ValidationError("Invalid percentage allocation")
276305
return multivariate_options
277306

307+
def validate_owners(self, owners: list[FFAdminUser]) -> list[FFAdminUser]:
308+
project: Project = self.context["project"]
309+
for user in owners:
310+
if not user.has_project_permission(VIEW_PROJECT, project):
311+
raise serializers.ValidationError(
312+
"Some users do not have access to this project."
313+
)
314+
return owners
315+
316+
def validate_group_owners(
317+
self, group_owners: list[UserPermissionGroup]
318+
) -> list[UserPermissionGroup]:
319+
project: Project = self.context["project"]
320+
for group in group_owners:
321+
if group.organisation_id != project.organisation_id:
322+
raise serializers.ValidationError(
323+
"Some groups do not belong to this project's organisation."
324+
)
325+
return group_owners
326+
278327
def validate_name(self, name: str): # type: ignore[no-untyped-def]
279328
view = self.context["view"]
280329

@@ -317,8 +366,23 @@ def validate(self, attrs: dict[str, Any]) -> dict[str, Any]:
317366
"Selected Tags must be from the same Project as current Feature"
318367
)
319368

369+
self._validate_enforce_feature_owners(attrs)
370+
320371
return attrs
321372

373+
def _validate_enforce_feature_owners(self, attrs: dict[str, Any]) -> None:
374+
project: Project = self.context["project"]
375+
if (
376+
not self.instance
377+
and project.enforce_feature_owners
378+
and not attrs.get("owners")
379+
and not attrs.get("group_owners")
380+
):
381+
raise serializers.ValidationError(
382+
"This project requires at least one owner or group owner "
383+
"when creating a feature."
384+
)
385+
322386
@extend_schema_field(FeatureStateSerializerSmall(allow_null=True))
323387
def get_environment_feature_state( # type: ignore[return]
324388
self, instance: Feature
@@ -399,6 +463,9 @@ def update(self, feature: Feature, validated_data: dict[str, Any]) -> Feature:
399463
class UpdateFeatureSerializerWithMetadata(FeatureSerializerWithMetadata):
400464
"""prevent users from changing certain values after creation"""
401465

466+
owners = _FeatureOwnersField(many=True, read_only=True)
467+
group_owners = _FeatureGroupOwnersField(many=True, read_only=True)
468+
402469
class Meta(FeatureSerializerWithMetadata.Meta):
403470
read_only_fields = FeatureSerializerWithMetadata.Meta.read_only_fields + ( # type: ignore[assignment]
404471
"default_enabled",
@@ -416,6 +483,9 @@ class ListFeatureSerializer(FeatureSerializerWithMetadata):
416483
class UpdateFeatureSerializer(ListFeatureSerializer):
417484
"""prevent users from changing certain values after creation"""
418485

486+
owners = _FeatureOwnersField(many=True, read_only=True)
487+
group_owners = _FeatureGroupOwnersField(many=True, read_only=True)
488+
419489
class Meta(ListFeatureSerializer.Meta):
420490
read_only_fields = ListFeatureSerializer.Meta.read_only_fields + ( # type: ignore[assignment]
421491
"default_enabled",

api/features/views.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -450,6 +450,11 @@ def remove_group_owners(self, request, *args, **kwargs): # type: ignore[no-unty
450450
feature = self.get_object()
451451
serializer = FeatureGroupOwnerInputSerializer(data=request.data)
452452
serializer.is_valid(raise_exception=True)
453+
self._validate_owner_removal(
454+
feature,
455+
owners_to_remove=0,
456+
group_owners_to_remove=len(serializer.validated_data["group_ids"]),
457+
)
453458
serializer.remove_group_owners(feature)
454459
response = Response(self.get_serializer(instance=feature).data)
455460
return response
@@ -483,10 +488,34 @@ def remove_owners(self, request, *args, **kwargs): # type: ignore[no-untyped-de
483488
serializer.is_valid(raise_exception=True)
484489

485490
feature = self.get_object()
491+
self._validate_owner_removal(
492+
feature,
493+
owners_to_remove=len(serializer.validated_data["user_ids"]),
494+
group_owners_to_remove=0,
495+
)
486496
serializer.remove_users(feature)
487497

488498
return Response(self.get_serializer(instance=feature).data)
489499

500+
def _validate_owner_removal(
501+
self,
502+
feature: Feature,
503+
owners_to_remove: int,
504+
group_owners_to_remove: int,
505+
) -> None:
506+
if not feature.project.enforce_feature_owners:
507+
return
508+
remaining = (
509+
feature.owners.count()
510+
- owners_to_remove
511+
+ feature.group_owners.count()
512+
- group_owners_to_remove
513+
)
514+
if remaining < 1:
515+
raise serializers.ValidationError(
516+
"This project requires at least one owner or group owner per feature."
517+
)
518+
490519
@extend_schema(
491520
parameters=[GetInfluxDataQuerySerializer],
492521
responses={200: FeatureInfluxDataSerializer()},
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# Generated by Django 5.2.11 on 2026-03-27 03:32
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
("projects", "0027_add_create_project_level_change_requests_permission"),
10+
]
11+
12+
operations = [
13+
migrations.AddField(
14+
model_name="project",
15+
name="enforce_feature_owners",
16+
field=models.BooleanField(
17+
default=False,
18+
help_text="Require at least one user or group owner when creating a feature.",
19+
),
20+
),
21+
]

api/projects/models.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,10 @@ class Project(LifecycleModelMixin, SoftDeleteExportableModel): # type: ignore[d
6060
default=False,
6161
help_text="Prevent defaults from being set in all environments when creating a feature.",
6262
)
63+
enforce_feature_owners = models.BooleanField(
64+
default=False,
65+
help_text="Require at least one user or group owner when creating a feature.",
66+
)
6367
enable_realtime_updates = models.BooleanField(
6468
default=False,
6569
help_text="Enable this to trigger a realtime(sse) event whenever the value of a flag changes",

api/projects/serializers.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ class Meta:
4444
"stale_flags_limit_days",
4545
"edge_v2_migration_status",
4646
"minimum_change_request_approvals",
47+
"enforce_feature_owners",
4748
)
4849
read_only_fields = (
4950
"enable_dynamo_db",

0 commit comments

Comments
 (0)