Skip to content

Commit 04df8d5

Browse files
authored
fix: improve _is_user_object_admin query performance (#6537)
1 parent c1ffe45 commit 04df8d5

5 files changed

Lines changed: 116 additions & 13 deletions

File tree

api/permissions/permission_service.py

Lines changed: 45 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -233,18 +233,53 @@ def master_api_key_has_organisation_permission(
233233
def _is_user_object_admin(
234234
user: "FFAdminUser", object_: Union[Project, Environment]
235235
) -> bool:
236-
ModelClass = type(object_)
237-
base_filter = get_base_permission_filter(user, ModelClass) # type: ignore[arg-type]
238-
filter_ = base_filter & Q(id=object_.id)
239-
240-
if ModelClass is Project:
241-
filter_ = filter_ & Q(organisation__users=user)
242-
elif ModelClass is Environment:
243-
filter_ = filter_ & Q(project__organisation__users=user)
236+
"""
237+
Check if user has admin permission on a project or environment.
238+
239+
Runs separate queries with early returns:
240+
1. Organisation membership - check to prevent orphaned permission
241+
records from granting access.
242+
2. Direct user permission - checks UserProjectPermission/UserEnvironmentPermission.
243+
3. Group permission - checks via user's group memberships.
244+
4. Role permission - RBAC check, only if enabled.
245+
246+
Returns as soon as permission is found, avoiding unnecessary subsequent queries.
247+
248+
"""
249+
model_class = type(object_)
250+
object_id = object_.id
251+
252+
# Check: verify user belongs to the organisation that owns this object
253+
if model_class is Project:
254+
if not Project.objects.filter(id=object_id, organisation__users=user).exists():
255+
return False
256+
elif model_class is Environment:
257+
if not Environment.objects.filter(
258+
id=object_id, project__organisation__users=user
259+
).exists():
260+
return False
244261
else: # pragma: no cover
245-
raise ValueError(f"Unexpected object type {type(object_)}")
262+
raise ValueError(f"Unexpected object type {model_class}")
263+
264+
# Check direct permission
265+
user_filter = get_user_permission_filter(user, allow_admin=True)
266+
if model_class.objects.filter(user_filter & Q(id=object_id)).exists():
267+
return True
246268

247-
return ModelClass.objects.filter(filter_).exists()
269+
# Check group permission
270+
group_filter = get_group_permission_filter(user, allow_admin=True)
271+
if model_class.objects.filter(group_filter & Q(id=object_id)).exists():
272+
return True
273+
274+
# Check role permission (only if RBAC installed)
275+
if settings.IS_RBAC_INSTALLED: # pragma: no cover
276+
role_filter = get_role_permission_filter(
277+
user, model_class, permission_key=None, allow_admin=True, tag_ids=None
278+
)
279+
if model_class.objects.filter(role_filter & Q(id=object_id)).exists():
280+
return True
281+
282+
return False
248283

249284

250285
def get_base_permission_filter( # type: ignore[no-untyped-def]

api/tests/unit/environments/test_unit_environments_views.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -717,7 +717,7 @@ def test_view_environment_with_staff__query_count_is_expected_without_rbac(
717717
environment_metadata_b,
718718
required_a_environment_metadata_field,
719719
environment_content_type,
720-
expected_query_count=9,
720+
expected_query_count=11,
721721
)
722722

723723

@@ -746,7 +746,7 @@ def test_view_environment_with_staff__query_count_is_expected_with_rbac(
746746
environment_metadata_b,
747747
required_a_environment_metadata_field,
748748
environment_content_type,
749-
expected_query_count=10,
749+
expected_query_count=12,
750750
)
751751

752752

api/tests/unit/permissions/permission_service/conftest.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import pytest
22

3+
from environments.models import Environment
34
from environments.permissions.models import (
45
UserEnvironmentPermission,
56
UserPermissionGroupEnvironmentPermission,
@@ -43,7 +44,9 @@ def project_admin_via_user_permission_group( # type: ignore[no-untyped-def]
4344

4445
@pytest.fixture
4546
def environment_admin_via_user_permission(
46-
staff_user: FFAdminUser, user_environment_permission: UserEnvironmentPermission
47+
staff_user: FFAdminUser,
48+
environment: Environment, # Explicitly depend on environment to ensure same instance
49+
user_environment_permission: UserEnvironmentPermission,
4750
) -> UserEnvironmentPermission:
4851
user_environment_permission.admin = True
4952
user_environment_permission.save()
@@ -61,6 +64,7 @@ def environment_admin_via_user_permission_group(
6164
user_environment_permission_group: UserPermissionGroupEnvironmentPermission,
6265
staff_user: FFAdminUser,
6366
user_permission_group: UserPermissionGroup,
67+
environment: Environment, # Explicitly depend on environment to ensure same instance
6468
) -> UserPermissionGroupEnvironmentPermission:
6569
user_permission_group.users.add(staff_user)
6670

api/tests/unit/permissions/permission_service/test_is_user_environment_admin.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,3 +134,38 @@ def test_is_user_environment_admin__does_not_return_environment_for_orphan_group
134134

135135
# Then
136136
assert not is_user_environment_admin(user=staff_user, environment=environment)
137+
138+
139+
def test_is_user_environment_admin__short_circuits_on_direct_permission(
140+
staff_user: FFAdminUser,
141+
environment: Environment,
142+
environment_admin_via_user_permission: UserEnvironmentPermission,
143+
django_assert_num_queries: typing.Any,
144+
) -> None:
145+
# When/Then - should take only 6 queries:
146+
# 1. Check if user is org admin (is_user_organisation_admin)
147+
# 2. Check organisation membership for project (_is_user_object_admin)
148+
# 3. Check direct user permission on project (not found)
149+
# 4. Check group permission on project (not found)
150+
# 5. Check organisation membership for environment (_is_user_object_admin)
151+
# 6. Check direct user permission on environment (short-circuits here)
152+
with django_assert_num_queries(6):
153+
assert is_user_environment_admin(staff_user, environment) is True
154+
155+
156+
def test_is_user_environment_admin__short_circuits_on_group_permission(
157+
staff_user: FFAdminUser,
158+
environment: Environment,
159+
environment_admin_via_user_permission_group: UserPermissionGroupEnvironmentPermission,
160+
django_assert_num_queries: typing.Any,
161+
) -> None:
162+
# When/Then - should take only 7 queries:
163+
# 1. Check if user is org admin (is_user_organisation_admin)
164+
# 2. Check organisation membership for project (_is_user_object_admin)
165+
# 3. Check direct user permission on project (not found)
166+
# 4. Check group permission on project (not found)
167+
# 5. Check organisation membership for environment (_is_user_object_admin)
168+
# 6. Check direct user permission on environment (not found)
169+
# 7. Check group permission on environment (short-circuits here)
170+
with django_assert_num_queries(7):
171+
assert is_user_environment_admin(staff_user, environment) is True

api/tests/unit/permissions/permission_service/test_is_user_project_admin.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,3 +96,32 @@ def test_is_user_project_admin__does_not_return_project_for_orphan_group_permiss
9696

9797
# Then
9898
assert not is_user_project_admin(user=staff_user, project=project)
99+
100+
101+
def test_is_user_project_admin__short_circuits_on_direct_permission(
102+
staff_user: FFAdminUser,
103+
project: Project,
104+
project_admin_via_user_permission: UserProjectPermission,
105+
django_assert_num_queries: typing.Any,
106+
) -> None:
107+
# When/Then - should take only 3 queries:
108+
# 1. Check if user is org admin (is_user_organisation_admin)
109+
# 2. Check organisation membership (_is_user_object_admin)
110+
# 3. Check direct user permission (short-circuits here)
111+
with django_assert_num_queries(3):
112+
assert is_user_project_admin(staff_user, project) is True
113+
114+
115+
def test_is_user_project_admin__short_circuits_on_group_permission(
116+
staff_user: FFAdminUser,
117+
project: Project,
118+
project_admin_via_user_permission_group: UserPermissionGroupProjectPermission,
119+
django_assert_num_queries: typing.Any,
120+
) -> None:
121+
# When/Then - should take only 4 queries:
122+
# 1. Check if user is org admin (is_user_organisation_admin)
123+
# 2. Check organisation membership (_is_user_object_admin)
124+
# 3. Check direct user permission (not found)
125+
# 4. Check group permission (short-circuits here)
126+
with django_assert_num_queries(4):
127+
assert is_user_project_admin(staff_user, project) is True

0 commit comments

Comments
 (0)