Skip to content

fix: count actual existing owners in _validate_owner_removal validation#8032

Open
Marnie0415 wants to merge 3 commits into
Flagsmith:mainfrom
Marnie0415:fix/validate-owner-removal-count
Open

fix: count actual existing owners in _validate_owner_removal validation#8032
Marnie0415 wants to merge 3 commits into
Flagsmith:mainfrom
Marnie0415:fix/validate-owner-removal-count

Conversation

@Marnie0415

@Marnie0415 Marnie0415 commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

Problem

When enforce_feature_owners is enabled, the _validate_owner_removal method validates whether removing feature owners would leave the feature with zero owners. It uses the count of IDs in the request rather than the count of IDs that actually exist as owners on the feature.

The group owner serializer (FeatureGroupOwnerInputSerializer) does not validate that group IDs exist before processing, so non-existent group IDs pass through to _validate_owner_removal and inflate the removal count. This causes the validation to incorrectly block legitimate operations.

Note: The user owner serializer (UserIdsSerializer) already validates all IDs exist before the view runs, so this bug is only reachable via the group owner endpoint.

How to reproduce

  1. Create a project with enforce_feature_owners = True
  2. Create a feature with 1 user owner and 1 group owner
  3. Call POST /api/v1/projects/{id}/features/{id}/remove-group-owners/ with {"group_ids": [group_id, 999999]}
  4. Expected: 200 OK - the real group is removed, user owner remains
  5. Actual: 400 Bad Request - validation calculates 1 + 1 - 2 = 0 and rejects

When does this happen in practice?

  • Frontend sends stale group IDs after a concurrent group removal
  • API consumers build group ID lists from cached data that is slightly outdated
  • Bulk operations that include IDs from different features

Solution

Filter requested IDs against actual existing owners before counting.

Files changed

  • api/features/views.py - _validate_owner_removal now accepts owner_ids: set[int] and group_owner_ids: set[int] instead of plain counts. Both callers (remove_owners, remove_group_owners) updated accordingly.
  • api/tests/unit/features/test_unit_features_views.py - Added 2 test cases for the non-existent group ID scenarios.

Performance

Only queries existing IDs when the corresponding input set is non-empty, avoiding unnecessary DB calls.

Scope

This PR fixes the validation count mismatch for group owners. The user owner path is already safe because UserIdsSerializer validates IDs before the view. The behavior where M2M remove() silently ignores non-existent IDs is by design in Django and is a separate concern.

@Marnie0415
Marnie0415 requested a review from a team as a code owner July 16, 2026 18:02
@Marnie0415
Marnie0415 requested review from emyller and removed request for a team July 16, 2026 18:02
@vercel

vercel Bot commented Jul 16, 2026

Copy link
Copy Markdown

@mimocode is attempting to deploy a commit to the Flagsmith Team on Vercel.

A member of the Team first needs to authorize it.

@coderabbitai

coderabbitai Bot commented Jul 16, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Feature owner and group-owner removal validation now accepts requested ID sets and counts only IDs that exist on the feature before enforcing the minimum-owner rule. Tests cover nonexistent IDs alongside real removals, including blocked and permitted outcomes for users and group owners.

Estimated code review effort: 3 (Moderate) | ~20 minutes


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added the api Issue related to the REST API label Jul 16, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1


ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 76b6a08d-e537-44cf-80b3-dd6331d5df49

📥 Commits

Reviewing files that changed from the base of the PR and between 4df9bfb and 2b12acf.

📒 Files selected for processing (2)
  • api/features/views.py
  • api/tests/unit/features/test_unit_features_views.py

Comment thread api/features/views.py Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (1)
api/features/views.py (1)

490-503: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Use prefetched relations to avoid additional database queries.

Since both owners and group_owners are already prefetched in the get_queryset method, using .values_list() and .count() will ignore the prefetch cache and trigger four additional database queries. You can iterate over the prefetched .all() objects and use len() to perform these checks efficiently in memory.

⚡ Proposed refactor to utilise the prefetch cache
-        existing_owner_ids = set(feature.owners.values_list("id", flat=True))
-        existing_group_owner_ids = set(
-            feature.group_owners.values_list("id", flat=True)
-        )
-
-        actual_owners_to_remove = len(owner_ids & existing_owner_ids)
-        actual_group_owners_to_remove = len(group_owner_ids & existing_group_owner_ids)
-
-        remaining = (
-            feature.owners.count()
-            - actual_owners_to_remove
-            + feature.group_owners.count()
-            - actual_group_owners_to_remove
-        )
+        existing_owners = feature.owners.all()
+        existing_group_owners = feature.group_owners.all()
+
+        existing_owner_ids = {owner.id for owner in existing_owners}
+        existing_group_owner_ids = {group.id for group in existing_group_owners}
+
+        actual_owners_to_remove = len(owner_ids & existing_owner_ids)
+        actual_group_owners_to_remove = len(group_owner_ids & existing_group_owner_ids)
+
+        remaining = (
+            len(existing_owners)
+            - actual_owners_to_remove
+            + len(existing_group_owners)
+            - actual_group_owners_to_remove
+        )

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 879f1ff3-8368-4e65-b21f-4df8248438ae

📥 Commits

Reviewing files that changed from the base of the PR and between 2b12acf and dfd4c60.

📒 Files selected for processing (1)
  • api/features/views.py

@Marnie0415
Marnie0415 force-pushed the fix/validate-owner-removal-count branch from dfd4c60 to eda5fb1 Compare July 16, 2026 18:13

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (1)
api/features/views.py (1)

490-508: 🧹 Nitpick | 🔵 Trivial

Utilise prefetched relations to avoid additional database queries.

As noted in a previous review, because both owners and group_owners are prefetched in the get_queryset method, using .values_list("id", flat=True) and .count() bypasses the prefetch cache and triggers additional database queries.

Although the if owner_ids and if group_owner_ids conditionals attempt to skip queries when the sets are empty, the subsequent .count() calls will still trigger database queries unconditionally. Evaluating feature.owners.all() and using len() allows these checks to be performed efficiently in memory.

⚡ Proposed refactor to utilise the prefetch cache
-        actual_owners_to_remove = (
-            len(owner_ids & set(feature.owners.values_list("id", flat=True)))
-            if owner_ids
-            else 0
-        )
-        actual_group_owners_to_remove = (
-            len(
-                group_owner_ids & set(feature.group_owners.values_list("id", flat=True))
-            )
-            if group_owner_ids
-            else 0
-        )
+        existing_owners = feature.owners.all()
+        existing_group_owners = feature.group_owners.all()
+
+        actual_owners_to_remove = (
+            len(owner_ids & {owner.id for owner in existing_owners})
+            if owner_ids
+            else 0
+        )
+        actual_group_owners_to_remove = (
+            len(group_owner_ids & {group.id for group in existing_group_owners})
+            if group_owner_ids
+            else 0
+        )
 
         remaining = (
-            feature.owners.count()
+            len(existing_owners)
             - actual_owners_to_remove
-            + feature.group_owners.count()
+            len(existing_group_owners)
             - actual_group_owners_to_remove
         )

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 273abbe0-f552-414e-96f8-f9259e4c2df0

📥 Commits

Reviewing files that changed from the base of the PR and between dfd4c60 and 7640828.

📒 Files selected for processing (2)
  • api/features/views.py
  • api/tests/unit/features/test_unit_features_views.py
💤 Files with no reviewable changes (1)
  • api/tests/unit/features/test_unit_features_views.py

@Marnie0415
Marnie0415 force-pushed the fix/validate-owner-removal-count branch from 7640828 to 4bfe95b Compare July 16, 2026 18:26

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (1)
api/features/views.py (1)

490-508: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Use prefetched relations to avoid additional database queries.

Since both owners and group_owners are already prefetched in the get_queryset method, using .values_list("id", flat=True) and .count() will ignore the prefetch cache and trigger four additional database queries. You can iterate over the prefetched .all() objects and use len() to perform these checks efficiently in memory.

⚡ Proposed refactor to utilise the prefetch cache
-        actual_owners_to_remove = (
-            len(owner_ids & set(feature.owners.values_list("id", flat=True)))
-            if owner_ids
-            else 0
-        )
-        actual_group_owners_to_remove = (
-            len(
-                group_owner_ids & set(feature.group_owners.values_list("id", flat=True))
-            )
-            if group_owner_ids
-            else 0
-        )
-
-        remaining = (
-            feature.owners.count()
-            - actual_owners_to_remove
-            + feature.group_owners.count()
-            - actual_group_owners_to_remove
-        )
+        existing_owners = feature.owners.all()
+        existing_group_owners = feature.group_owners.all()
+
+        actual_owners_to_remove = (
+            len(owner_ids & {owner.id for owner in existing_owners})
+            if owner_ids
+            else 0
+        )
+        actual_group_owners_to_remove = (
+            len(group_owner_ids & {group.id for group in existing_group_owners})
+            if group_owner_ids
+            else 0
+        )
+
+        remaining = (
+            len(existing_owners)
+            - actual_owners_to_remove
+            + len(existing_group_owners)
+            - actual_group_owners_to_remove
+        )

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 2b8ad9c9-ad39-42bb-bcdc-e9a9e9c3fcf9

📥 Commits

Reviewing files that changed from the base of the PR and between 7640828 and cc30a3e.

📒 Files selected for processing (2)
  • api/features/views.py
  • api/tests/unit/features/test_unit_features_views.py

@Marnie0415
Marnie0415 force-pushed the fix/validate-owner-removal-count branch from cc30a3e to bc18412 Compare July 16, 2026 18:31

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
api/tests/unit/features/test_unit_features_views.py (2)

5431-5432: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Use an existing, non-owner group for consistency and robustness.

While FeatureGroupOwnerInputSerializer does not currently validate group existence like UserIdsSerializer does, using a hardcoded non-existent group ID (888888) is brittle and inconsistent. Create and use a real group that is not an owner to accurately simulate a stale ID being removed.


5366-5367: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Tests use non-existent IDs that fail at the serializer layer or cause brittle assertions.

Using hardcoded non-existent IDs (like 999999 or 888888) does not correctly test the new _validate_owner_removal logic. For users, UserIdsSerializer.validate strictly checks database existence and immediately returns 400 Bad Request ("Some users not found"). This means one test passes for the wrong reason and another will fail outright, as the view logic is never reached. To correctly simulate "stale" removals, create real entities and pass their IDs without adding them as feature owners (and optionally update the test names to reflect this).

  • api/tests/unit/features/test_unit_features_views.py#L5366-L5367: Create a real non_owner_user and use their ID here so the 400 Bad Request is legitimately triggered by _validate_owner_removal instead of the serializer.
  • api/tests/unit/features/test_unit_features_views.py#L5396-L5397: Use the non_owner_user ID here to bypass serializer validation and ensure the test receives the expected 200 OK.
  • api/tests/unit/features/test_unit_features_views.py#L5431-L5432: Create a real non_owner_group and use its ID here for consistency and to prevent future serializer changes from breaking the test.
🛠️ Proposed fixes

For lines 5366-5367:

-    # Request removes the real owner AND a non-existent user
-    data = {"user_ids": [admin_user.id, 999999]}
+    # Request removes the real owner AND a user who is not an owner
+    non_owner = FFAdminUser.objects.create_user(email="nonowner1@example.com")  # type: ignore[no-untyped-call]
+    data = {"user_ids": [admin_user.id, non_owner.id]}

For lines 5396-5397:

-    # Request removes one real owner and one non-existent user
-    data = {"user_ids": [admin_user.id, 999999]}
+    # Request removes one real owner and a user who is not an owner
+    non_owner = FFAdminUser.objects.create_user(email="nonowner2@example.com")  # type: ignore[no-untyped-call]
+    data = {"user_ids": [admin_user.id, non_owner.id]}

For lines 5431-5432:

-    # Request removes the real group and a non-existent group
-    data = {"group_ids": [group.id, 888888]}
+    # Request removes the real group and a group that is not an owner
+    non_owner_group = UserPermissionGroup.objects.create(name="Non Owner Group", organisation=organisation)
+    data = {"group_ids": [group.id, non_owner_group.id]}

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 1d0111d9-7c02-4a7a-b3c7-7e8fc9c5191d

📥 Commits

Reviewing files that changed from the base of the PR and between cc30a3e and f15c4a6.

📒 Files selected for processing (2)
  • api/features/views.py
  • api/tests/unit/features/test_unit_features_views.py

@Marnie0415
Marnie0415 force-pushed the fix/validate-owner-removal-count branch 4 times, most recently from a0bd65e to c95ba38 Compare July 16, 2026 19:25
@codecov

codecov Bot commented Jul 16, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 98.50%. Comparing base (1c73453) to head (d08d9fb).
⚠️ Report is 29 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #8032      +/-   ##
==========================================
- Coverage   98.63%   98.50%   -0.14%     
==========================================
  Files        1496     1506      +10     
  Lines       59072    59589     +517     
==========================================
+ Hits        58266    58696     +430     
- Misses        806      893      +87     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@Marnie0415
Marnie0415 force-pushed the fix/validate-owner-removal-count branch from d6811b2 to cf02027 Compare July 16, 2026 19:38
When enforce_feature_owners is enabled, _validate_owner_removal was using
the count of IDs provided in the request rather than the count of IDs
that actually exist as owners. This caused false rejections when
non-existent IDs were included in the group owner removal request.

Changed the method to accept ID sets and filter against actual existing
owners before counting. Added test cases covering the bug scenario.
@Marnie0415
Marnie0415 force-pushed the fix/validate-owner-removal-count branch from a862bba to 03a14eb Compare July 16, 2026 19:40
@Marnie0415

Copy link
Copy Markdown
Contributor Author

@emyller Hi! This PR is ready for your review. All CI checks pass. The fix addresses a validation count mismatch in _validate_owner_removal for group owners. Thanks!

@emyller emyller left a comment

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.

The changes look sound, thanks for this contribution!

I have a few comments to improve overall quality. I'm sorry we're still improving such practices ourselves.

Also: is this work related to an existing issue in GitHub? We'd appreciate if you took a minute to read and follow our contributing guide. Thanks again!

Comment thread api/features/views.py Outdated
Comment on lines 490 to 512

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.

I understand the code upstream suggests count arithmetic but I believe we could improve this twofold:

  • By only fetching IDs from the database
  • By using set arithmetic as a lever
Suggested change
existing_owner_ids = set(feature.owners.values_list("id", flat=True))
existing_group_owner_ids = set(feature.group_owners.values_list("id", flat=True))
if not (
(existing_owner_ids - owner_ids)
or (existing_group_owner_ids - group_owner_ids)
):
raise serializers.ValidationError(
"This project requires at least one owner or group owner per feature."
)

feature: Feature,
admin_user: FFAdminUser,
) -> None:
# Given — feature has only one owner, enforce_feature_owners is on

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.

Please avoid adding these comments. If code needs clarification, please do so in better naming, unless it can't help. I think none of the code you're adding to tests needs clarification.

Comment on lines +5364 to +5374
url = reverse(
"api-v1:projects:project-features-remove-owners",
args=[project.id, feature.id],
)
# Request removes the real owner AND a user who is not an owner
data = {"user_ids": [admin_user.id, non_owner.id]}

# When
response = admin_client_new.post(
url, data=json.dumps(data), content_type="application/json"
)

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.

Please inline the real URL and the payload to the client call. This improves reading tests, and exercises the URL itself — e.g. reverse could help perpetuating a typo in the API spec, because the actual URL isn't exercised.

Suggested change
url = reverse(
"api-v1:projects:project-features-remove-owners",
args=[project.id, feature.id],
)
# Request removes the real owner AND a user who is not an owner
data = {"user_ids": [admin_user.id, non_owner.id]}
# When
response = admin_client_new.post(
url, data=json.dumps(data), content_type="application/json"
)
# When
response = admin_client_new.post(
f"/the/real/url/{project.id}",
data={"user_ids": [admin_user.id, non_owner.id]},
format="json",
)

@Marnie0415

Copy link
Copy Markdown
Contributor Author

Hi @emyller, thanks for the thorough review! I've addressed all your feedback:

Changes made:

  1. Simplified _validate_owner_removal using �alues_list(id, flat=True) and set arithmetic as suggested
  2. Removed # Given/# When/# Then comments from new tests
  3. Used inline URLs and ormat=json instead of
    everse() and json.dumps()

Regarding the issue: I searched the existing issues but couldn't find one related to this bug. Would you like me to create a new issue first, or is it fine to proceed without one for this validation fix?

Use values_list('id', flat=True) to fetch only IDs instead of full objects,
and use set difference to check remaining owners instead of counting.

Also update new tests to use inline URLs and format='json' per review
feedback.
@Marnie0415
Marnie0415 force-pushed the fix/validate-owner-removal-count branch from dd5974e to d08d9fb Compare July 17, 2026 01:07
@Marnie0415

Copy link
Copy Markdown
Contributor Author

URL verification: Confirmed the inline URLs match the actual route definitions:

# views.py defines:
@action(detail=True, methods=["POST"], url_path="remove-owners")
@action(detail=True, methods=["POST"], url_path="remove-group-owners")

# Existing tests already use the same base pattern:
/api/v1/projects/{project.id}/features/        (lines 4064, 4638)

# The URL resolves to:
/api/v1/projects/{project.id}/features/{feature.id}/remove-owners/
/api/v1/projects/{project.id}/features/{feature.id}/remove-group-owners/

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

api Issue related to the REST API

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants