Skip to content

Commit ca55b0d

Browse files
committed
feat: add project setting to enforce feature ownership (#4432)
Add an `enforce_feature_owners` boolean on the Project model. When enabled, feature creation requires at least one user or group owner. The owners/group_owners fields on CreateFeatureSerializer are now asymmetric PrimaryKeyRelatedFields (accept IDs on write, return nested objects on read). The remove-owners and remove-group-owners endpoints also prevent removing the last owner when enforcement is on. Frontend adds the project setting toggle, a FeatureOwnerSelect component for the creation modal, create-button validation, and owner chips in the feature modal header.
1 parent 3f71f16 commit ca55b0d

17 files changed

Lines changed: 907 additions & 22 deletions

File tree

api/features/serializers.py

Lines changed: 74 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,15 @@
33
from uuid import UUID
44

55
import django.core.exceptions
6+
from django.db import models
67
from common.features.multivariate.serializers import (
78
MultivariateFeatureStateValueSerializer,
89
)
910
from common.features.serializers import (
1011
CreateSegmentOverrideFeatureStateSerializer,
1112
FeatureStateValueSerializer,
1213
)
14+
from common.projects.permissions import VIEW_PROJECT
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,30 @@ 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+
169+
def get_queryset(self) -> models.QuerySet[FFAdminUser]:
170+
return FFAdminUser.objects.all()
171+
172+
def to_representation(self, value: FFAdminUser) -> dict[str, Any]:
173+
return UserListSerializer(value).data
174+
175+
176+
class _FeatureGroupOwnersField(serializers.PrimaryKeyRelatedField[UserPermissionGroup]):
177+
178+
def get_queryset(self) -> models.QuerySet[UserPermissionGroup]:
179+
return UserPermissionGroup.objects.all()
180+
181+
def to_representation(self, value: UserPermissionGroup) -> dict[str, Any]:
182+
return UserPermissionGroupSummarySerializer(value).data
183+
184+
164185
class CreateFeatureSerializer(DeleteBeforeUpdateWritableNestedModelSerializer):
165186
multivariate_options = NestedMultivariateFeatureOptionSerializer(
166187
many=True, required=False
167188
)
168-
owners = UserListSerializer(many=True, read_only=True)
169-
group_owners = UserPermissionGroupSummarySerializer(many=True, read_only=True)
189+
owners = _FeatureOwnersField(many=True, required=False)
190+
group_owners = _FeatureGroupOwnersField(many=True, required=False)
170191

171192
environment_feature_state = serializers.SerializerMethodField()
172193
segment_feature_state = serializers.SerializerMethodField()
@@ -240,12 +261,22 @@ def create(self, validated_data: dict) -> Feature: # type: ignore[type-arg]
240261
project = self.context["project"]
241262
self.validate_project_features_limit(project)
242263

264+
# Pop M2M fields before creating the instance (can't pass to Model.objects.create)
265+
owners: list[FFAdminUser] = validated_data.pop("owners", [])
266+
group_owners: list[UserPermissionGroup] = validated_data.pop("group_owners", [])
267+
243268
# Add the default(User creating the feature) owner of the feature
244269
# NOTE: pop the user before passing the data to create
245270
user = validated_data.pop("user", None)
246271
instance = super(CreateFeatureSerializer, self).create(validated_data) # type: ignore[no-untyped-call]
247272
if user and getattr(user, "is_master_api_key_user", False) is False:
248273
instance.owners.add(user)
274+
275+
if owners:
276+
instance.owners.add(*owners)
277+
if group_owners:
278+
instance.group_owners.add(*group_owners)
279+
249280
return instance # type: ignore[no-any-return]
250281

251282
def validate_project_features_limit(self, project: Project) -> None:
@@ -275,6 +306,26 @@ def validate_multivariate_options(self, multivariate_options): # type: ignore[n
275306
raise serializers.ValidationError("Invalid percentage allocation")
276307
return multivariate_options
277308

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

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

371+
self._validate_enforce_feature_owners(attrs)
372+
320373
return attrs
321374

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

468+
owners = _FeatureOwnersField(many=True, read_only=True)
469+
group_owners = _FeatureGroupOwnersField(many=True, read_only=True)
470+
402471
class Meta(FeatureSerializerWithMetadata.Meta):
403472
read_only_fields = FeatureSerializerWithMetadata.Meta.read_only_fields + ( # type: ignore[assignment]
404473
"default_enabled",
@@ -416,6 +485,9 @@ class ListFeatureSerializer(FeatureSerializerWithMetadata):
416485
class UpdateFeatureSerializer(ListFeatureSerializer):
417486
"""prevent users from changing certain values after creation"""
418487

488+
owners = _FeatureOwnersField(many=True, read_only=True)
489+
group_owners = _FeatureGroupOwnersField(many=True, read_only=True)
490+
419491
class Meta(ListFeatureSerializer.Meta):
420492
read_only_fields = ListFeatureSerializer.Meta.read_only_fields + ( # type: ignore[assignment]
421493
"default_enabled",

api/features/views.py

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

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

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

501+
def _validate_owner_removal(
502+
self,
503+
feature: Feature,
504+
owners_to_remove: int,
505+
group_owners_to_remove: int,
506+
) -> None:
507+
if not feature.project.enforce_feature_owners:
508+
return
509+
remaining = (
510+
feature.owners.count()
511+
- owners_to_remove
512+
+ feature.group_owners.count()
513+
- group_owners_to_remove
514+
)
515+
if remaining < 1:
516+
raise serializers.ValidationError(
517+
"This project requires at least one owner or group owner per feature."
518+
)
519+
491520
@extend_schema(
492521
parameters=[GetInfluxDataQuerySerializer],
493522
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)