From da13333ed8b330c613aa76dfbdf0a8e14626777f Mon Sep 17 00:00:00 2001 From: Srijan Tripathi Date: Thu, 6 Aug 2026 06:17:18 +0000 Subject: [PATCH 01/26] fix: resolve slow DB query in org permission check (#8228) - Rewrote `user_has_organisation_permission` to evaluate user, group, and role permissions using sequential `EXISTS` checks. - This prevents Django from building a massive `LEFT OUTER JOIN` cross-product of the entire RBAC graph, eliminating the ~2.6s loading delay on the `/groups/` endpoint. - Added `test_user_has_organisation_permission_query_count` to prevent future regressions. --- api/permissions/permission_service.py | 32 +++++++++++-------- .../unit/users/test_unit_users_models.py | 26 +++++++++++++++ 2 files changed, 44 insertions(+), 14 deletions(-) diff --git a/api/permissions/permission_service.py b/api/permissions/permission_service.py index 07b25b4124c4..2637ca7c095e 100644 --- a/api/permissions/permission_service.py +++ b/api/permissions/permission_service.py @@ -202,24 +202,28 @@ def user_has_organisation_permission( if is_user_organisation_admin(user, organisation): return True - # NOTE: since we store organisation admin slightly differently - # compared to project and environment `get_base_permission_filter` - # with allow_admin=True will not work for organisation - base_filter = get_base_permission_filter( - user, - Organisation, # type: ignore[arg-type] - permission_key, - allow_admin=False, - ) - filter_ = base_filter & Q(id=organisation.id) + # Base query to ensure the user actually belongs to the organisation + base_qs = Organisation.objects.filter(id=organisation.id, users=user) - queryset = Organisation.objects.filter(filter_) + # 1. Check direct user permissions (Fastest) + user_filter = get_user_permission_filter(user, permission_key, allow_admin=False) + if base_qs.filter(user_filter).exists(): + return True - # Final check to verify that user belongs to organisation - queryset = queryset.filter(users=user) + # 2. Check group permissions + group_filter = get_group_permission_filter(user, permission_key, allow_admin=False) + if base_qs.filter(group_filter).exists(): + return True - return queryset.exists() # type: ignore[no-any-return] + # 3. Check role permissions (only if RBAC is installed) + if settings.IS_RBAC_INSTALLED: # pragma: no cover + role_filter = get_role_permission_filter( + user, Organisation, permission_key, allow_admin=False + ) + if base_qs.filter(role_filter).exists(): + return True + return False def master_api_key_has_organisation_permission( master_api_key: "MasterAPIKey", organisation: Organisation, permission_key: str diff --git a/api/tests/unit/users/test_unit_users_models.py b/api/tests/unit/users/test_unit_users_models.py index 475a28af9647..d720523c6712 100644 --- a/api/tests/unit/users/test_unit_users_models.py +++ b/api/tests/unit/users/test_unit_users_models.py @@ -3,6 +3,7 @@ import pytest from common.projects.permissions import VIEW_PROJECT from django.db.utils import IntegrityError +from users.models import user_has_organisation_permission from organisations.models import Organisation, OrganisationRole from organisations.permissions.models import UserOrganisationPermission @@ -257,3 +258,28 @@ def test_email_domain__valid_email__returns_domain(): # type: ignore[no-untyped # Given / When # Then assert FFAdminUser(email="test@example.com").email_domain == "example.com" + +@pytest.mark.django_db +def test_user_has_organisation_permission_query_count( + django_assert_max_num_queries, + django_user_model, + organisation, +): + # Given + user = django_user_model.objects.create(email="test_query_count@example.com") + user.add_organisation(organisation) + + # When / Then + # We assert that checking permissions takes a maximum of 4 DB queries + # (1 base check + up to 3 for user/group/role filters) + # If a join explosion is reintroduced, this will fail because the query + # complexity and count will change drastically. + with django_assert_max_num_queries(4): + has_permission = user_has_organisation_permission( + user=user, + organisation=organisation, + permission_key="MANAGE_USER_GROUPS" + ) + + # The user has no explicit permissions in this setup, so it should return False + assert has_permission is False \ No newline at end of file From 5c5c4c0c1502a223f63f0211778862201e48f94e Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 6 Aug 2026 06:19:30 +0000 Subject: [PATCH 02/26] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- api/permissions/permission_service.py | 1 + .../unit/users/test_unit_users_models.py | 20 ++++++++++--------- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/api/permissions/permission_service.py b/api/permissions/permission_service.py index 2637ca7c095e..68d769c0ce10 100644 --- a/api/permissions/permission_service.py +++ b/api/permissions/permission_service.py @@ -225,6 +225,7 @@ def user_has_organisation_permission( return False + def master_api_key_has_organisation_permission( master_api_key: "MasterAPIKey", organisation: Organisation, permission_key: str ) -> bool: diff --git a/api/tests/unit/users/test_unit_users_models.py b/api/tests/unit/users/test_unit_users_models.py index d720523c6712..688146cda43e 100644 --- a/api/tests/unit/users/test_unit_users_models.py +++ b/api/tests/unit/users/test_unit_users_models.py @@ -3,14 +3,17 @@ import pytest from common.projects.permissions import VIEW_PROJECT from django.db.utils import IntegrityError -from users.models import user_has_organisation_permission from organisations.models import Organisation, OrganisationRole from organisations.permissions.models import UserOrganisationPermission from organisations.permissions.permissions import ORGANISATION_PERMISSIONS from projects.models import Project from tests.types import WithProjectPermissionsCallable -from users.models import FFAdminUser, UserPermissionGroup +from users.models import ( + FFAdminUser, + UserPermissionGroup, + user_has_organisation_permission, +) def test_belongs_to__user_in_organisation__returns_true( @@ -259,6 +262,7 @@ def test_email_domain__valid_email__returns_domain(): # type: ignore[no-untyped # Then assert FFAdminUser(email="test@example.com").email_domain == "example.com" + @pytest.mark.django_db def test_user_has_organisation_permission_query_count( django_assert_max_num_queries, @@ -270,16 +274,14 @@ def test_user_has_organisation_permission_query_count( user.add_organisation(organisation) # When / Then - # We assert that checking permissions takes a maximum of 4 DB queries + # We assert that checking permissions takes a maximum of 4 DB queries # (1 base check + up to 3 for user/group/role filters) - # If a join explosion is reintroduced, this will fail because the query + # If a join explosion is reintroduced, this will fail because the query # complexity and count will change drastically. with django_assert_max_num_queries(4): has_permission = user_has_organisation_permission( - user=user, - organisation=organisation, - permission_key="MANAGE_USER_GROUPS" + user=user, organisation=organisation, permission_key="MANAGE_USER_GROUPS" ) - + # The user has no explicit permissions in this setup, so it should return False - assert has_permission is False \ No newline at end of file + assert has_permission is False From 3869f7bae8fab3674d48f7af1a9560bafa214a62 Mon Sep 17 00:00:00 2001 From: Srijan Tripathi Date: Thu, 6 Aug 2026 10:21:16 +0000 Subject: [PATCH 03/26] Add # type: ignore[arg-type] --- api/permissions/permission_service.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/api/permissions/permission_service.py b/api/permissions/permission_service.py index 68d769c0ce10..fcd934a98f05 100644 --- a/api/permissions/permission_service.py +++ b/api/permissions/permission_service.py @@ -218,7 +218,10 @@ def user_has_organisation_permission( # 3. Check role permissions (only if RBAC is installed) if settings.IS_RBAC_INSTALLED: # pragma: no cover role_filter = get_role_permission_filter( - user, Organisation, permission_key, allow_admin=False + user, + Organisation, # type: ignore[arg-type] + permission_key, + allow_admin=False ) if base_qs.filter(role_filter).exists(): return True From 9cd011c697fb3ce35d15a3fd8a6c357a655e848b Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 6 Aug 2026 10:21:37 +0000 Subject: [PATCH 04/26] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- api/permissions/permission_service.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/api/permissions/permission_service.py b/api/permissions/permission_service.py index fcd934a98f05..66cdc2c0d365 100644 --- a/api/permissions/permission_service.py +++ b/api/permissions/permission_service.py @@ -219,9 +219,9 @@ def user_has_organisation_permission( if settings.IS_RBAC_INSTALLED: # pragma: no cover role_filter = get_role_permission_filter( user, - Organisation, # type: ignore[arg-type] + Organisation, # type: ignore[arg-type] permission_key, - allow_admin=False + allow_admin=False, ) if base_qs.filter(role_filter).exists(): return True From 6b726172b48b07c3ae837369c805331b0f9f9cb1 Mon Sep 17 00:00:00 2001 From: Srijan Tripathi Date: Thu, 6 Aug 2026 10:25:49 +0000 Subject: [PATCH 05/26] fix: rename test to match FT003 linting convention --- api/tests/unit/users/test_unit_users_models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/tests/unit/users/test_unit_users_models.py b/api/tests/unit/users/test_unit_users_models.py index 688146cda43e..8f63f0c1d07d 100644 --- a/api/tests/unit/users/test_unit_users_models.py +++ b/api/tests/unit/users/test_unit_users_models.py @@ -264,7 +264,7 @@ def test_email_domain__valid_email__returns_domain(): # type: ignore[no-untyped @pytest.mark.django_db -def test_user_has_organisation_permission_query_count( +def test_user_has_organisation_permission__evaluating_permission__executes_max_4_queries( django_assert_max_num_queries, django_user_model, organisation, From 08313423761a8a5b82a1e2b3edb77e0aca4beb0e Mon Sep 17 00:00:00 2001 From: Srijan Tripathi Date: Thu, 6 Aug 2026 10:29:07 +0000 Subject: [PATCH 06/26] chore: document reason for Organisation type suppression --- api/permissions/permission_service.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/api/permissions/permission_service.py b/api/permissions/permission_service.py index 66cdc2c0d365..3c2bc3a6232d 100644 --- a/api/permissions/permission_service.py +++ b/api/permissions/permission_service.py @@ -219,6 +219,8 @@ def user_has_organisation_permission( if settings.IS_RBAC_INSTALLED: # pragma: no cover role_filter = get_role_permission_filter( user, + # Type gap: get_role_permission_filter type hint expects an instance, + # but safely handles the model class at runtime. Organisation, # type: ignore[arg-type] permission_key, allow_admin=False, From fb0b063851656f881c36675e15be69f42b831986 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 6 Aug 2026 10:29:23 +0000 Subject: [PATCH 07/26] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- api/permissions/permission_service.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/permissions/permission_service.py b/api/permissions/permission_service.py index 3c2bc3a6232d..33381b44908a 100644 --- a/api/permissions/permission_service.py +++ b/api/permissions/permission_service.py @@ -219,7 +219,7 @@ def user_has_organisation_permission( if settings.IS_RBAC_INSTALLED: # pragma: no cover role_filter = get_role_permission_filter( user, - # Type gap: get_role_permission_filter type hint expects an instance, + # Type gap: get_role_permission_filter type hint expects an instance, # but safely handles the model class at runtime. Organisation, # type: ignore[arg-type] permission_key, From ba75c3f1c679b0dedc32da713f415e01fd3a8fec Mon Sep 17 00:00:00 2001 From: Srijan Tripathi Date: Thu, 6 Aug 2026 10:38:01 +0000 Subject: [PATCH 08/26] fix: resolve mypy strict typing errors for CI --- api/permissions/permission_service.py | 2 +- api/tests/unit/users/test_unit_users_models.py | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/api/permissions/permission_service.py b/api/permissions/permission_service.py index 33381b44908a..631ce527cc1d 100644 --- a/api/permissions/permission_service.py +++ b/api/permissions/permission_service.py @@ -221,7 +221,7 @@ def user_has_organisation_permission( user, # Type gap: get_role_permission_filter type hint expects an instance, # but safely handles the model class at runtime. - Organisation, # type: ignore[arg-type] + Organisation, permission_key, allow_admin=False, ) diff --git a/api/tests/unit/users/test_unit_users_models.py b/api/tests/unit/users/test_unit_users_models.py index 8f63f0c1d07d..c5fe8782f15a 100644 --- a/api/tests/unit/users/test_unit_users_models.py +++ b/api/tests/unit/users/test_unit_users_models.py @@ -12,9 +12,8 @@ from users.models import ( FFAdminUser, UserPermissionGroup, - user_has_organisation_permission, ) - +from permissions.permission_service import user_has_organisation_permission def test_belongs_to__user_in_organisation__returns_true( admin_user: FFAdminUser, @@ -268,7 +267,7 @@ def test_user_has_organisation_permission__evaluating_permission__executes_max_4 django_assert_max_num_queries, django_user_model, organisation, -): +)->None: # Given user = django_user_model.objects.create(email="test_query_count@example.com") user.add_organisation(organisation) From d129cda0b90ff19a54508fc6d5646258f87dee12 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 6 Aug 2026 10:38:17 +0000 Subject: [PATCH 09/26] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- api/permissions/permission_service.py | 2 +- api/tests/unit/users/test_unit_users_models.py | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/api/permissions/permission_service.py b/api/permissions/permission_service.py index 631ce527cc1d..9fcf7392e239 100644 --- a/api/permissions/permission_service.py +++ b/api/permissions/permission_service.py @@ -221,7 +221,7 @@ def user_has_organisation_permission( user, # Type gap: get_role_permission_filter type hint expects an instance, # but safely handles the model class at runtime. - Organisation, + Organisation, permission_key, allow_admin=False, ) diff --git a/api/tests/unit/users/test_unit_users_models.py b/api/tests/unit/users/test_unit_users_models.py index c5fe8782f15a..fe64af9a103f 100644 --- a/api/tests/unit/users/test_unit_users_models.py +++ b/api/tests/unit/users/test_unit_users_models.py @@ -7,13 +7,14 @@ from organisations.models import Organisation, OrganisationRole from organisations.permissions.models import UserOrganisationPermission from organisations.permissions.permissions import ORGANISATION_PERMISSIONS +from permissions.permission_service import user_has_organisation_permission from projects.models import Project from tests.types import WithProjectPermissionsCallable from users.models import ( FFAdminUser, UserPermissionGroup, ) -from permissions.permission_service import user_has_organisation_permission + def test_belongs_to__user_in_organisation__returns_true( admin_user: FFAdminUser, @@ -267,7 +268,7 @@ def test_user_has_organisation_permission__evaluating_permission__executes_max_4 django_assert_max_num_queries, django_user_model, organisation, -)->None: +) -> None: # Given user = django_user_model.objects.create(email="test_query_count@example.com") user.add_organisation(organisation) From 0349618b4185e37b96c709d48181abf0531867a9 Mon Sep 17 00:00:00 2001 From: Srijan Tripathi Date: Thu, 6 Aug 2026 10:42:29 +0000 Subject: [PATCH 10/26] fix: add missing type annotations for test fixture arguments --- api/tests/unit/users/test_unit_users_models.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/api/tests/unit/users/test_unit_users_models.py b/api/tests/unit/users/test_unit_users_models.py index fe64af9a103f..05fccfd85253 100644 --- a/api/tests/unit/users/test_unit_users_models.py +++ b/api/tests/unit/users/test_unit_users_models.py @@ -14,7 +14,7 @@ FFAdminUser, UserPermissionGroup, ) - +import typing def test_belongs_to__user_in_organisation__returns_true( admin_user: FFAdminUser, @@ -265,9 +265,9 @@ def test_email_domain__valid_email__returns_domain(): # type: ignore[no-untyped @pytest.mark.django_db def test_user_has_organisation_permission__evaluating_permission__executes_max_4_queries( - django_assert_max_num_queries, - django_user_model, - organisation, + django_assert_max_num_queries: typing.Any, + django_user_model: typing.Any, + organisation: typing.Any, ) -> None: # Given user = django_user_model.objects.create(email="test_query_count@example.com") From 9747b40fb8ca59018e1fba66a59e68c64d57d224 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 6 Aug 2026 10:42:44 +0000 Subject: [PATCH 11/26] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- api/tests/unit/users/test_unit_users_models.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/api/tests/unit/users/test_unit_users_models.py b/api/tests/unit/users/test_unit_users_models.py index 05fccfd85253..2de40b8c45e7 100644 --- a/api/tests/unit/users/test_unit_users_models.py +++ b/api/tests/unit/users/test_unit_users_models.py @@ -1,3 +1,4 @@ +import typing import uuid import pytest @@ -14,7 +15,7 @@ FFAdminUser, UserPermissionGroup, ) -import typing + def test_belongs_to__user_in_organisation__returns_true( admin_user: FFAdminUser, From 777dd96aaff330667b3a1f76e9a8cfb0cb5679ea Mon Sep 17 00:00:00 2001 From: Srijan Tripathi Date: Thu, 6 Aug 2026 10:46:13 +0000 Subject: [PATCH 12/26] fix: use typing.cast to satisfy strict PR review bot without triggering mypy unused-ignore --- api/permissions/permission_service.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/permissions/permission_service.py b/api/permissions/permission_service.py index 9fcf7392e239..57a367500f6b 100644 --- a/api/permissions/permission_service.py +++ b/api/permissions/permission_service.py @@ -221,7 +221,7 @@ def user_has_organisation_permission( user, # Type gap: get_role_permission_filter type hint expects an instance, # but safely handles the model class at runtime. - Organisation, + typing.cast(typing.Any, Organisation), permission_key, allow_admin=False, ) From c28865bc07ab819bb23b65e5f32512f92978df15 Mon Sep 17 00:00:00 2001 From: Srijan Tripathi Date: Thu, 6 Aug 2026 10:48:38 +0000 Subject: [PATCH 13/26] test: assert exact query shape to prevent massive join regression --- .../unit/users/test_unit_users_models.py | 29 ++++++++++++------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/api/tests/unit/users/test_unit_users_models.py b/api/tests/unit/users/test_unit_users_models.py index 2de40b8c45e7..c628f78b6b5a 100644 --- a/api/tests/unit/users/test_unit_users_models.py +++ b/api/tests/unit/users/test_unit_users_models.py @@ -15,7 +15,8 @@ FFAdminUser, UserPermissionGroup, ) - +from django.db import connection +from django.test.utils import CaptureQueriesContext def test_belongs_to__user_in_organisation__returns_true( admin_user: FFAdminUser, @@ -265,8 +266,8 @@ def test_email_domain__valid_email__returns_domain(): # type: ignore[no-untyped @pytest.mark.django_db -def test_user_has_organisation_permission__evaluating_permission__executes_max_4_queries( - django_assert_max_num_queries: typing.Any, +@pytest.mark.django_db +def test_user_has_organisation_permission__evaluating_permission__avoids_join_explosion( django_user_model: typing.Any, organisation: typing.Any, ) -> None: @@ -274,15 +275,23 @@ def test_user_has_organisation_permission__evaluating_permission__executes_max_4 user = django_user_model.objects.create(email="test_query_count@example.com") user.add_organisation(organisation) - # When / Then - # We assert that checking permissions takes a maximum of 4 DB queries - # (1 base check + up to 3 for user/group/role filters) - # If a join explosion is reintroduced, this will fail because the query - # complexity and count will change drastically. - with django_assert_max_num_queries(4): + # When + with CaptureQueriesContext(connection) as ctx: has_permission = user_has_organisation_permission( user=user, organisation=organisation, permission_key="MANAGE_USER_GROUPS" ) - # The user has no explicit permissions in this setup, so it should return False + # Then assert has_permission is False + + # The old regression executed exactly 1 massive query. + # The new logic evaluates sequentially, executing 3 to 4 isolated queries in the worst case. + assert len(ctx.captured_queries) > 1 + + # Guard against the SQL shape regression: + # The expensive join cross-product evaluated user, group, and role permissions in a single statement. + # We verify that no single executed query attempts to join multiple permission tables together. + for query in ctx.captured_queries: + sql = query["sql"].lower() + is_massive_join = "userpermission" in sql and "grouppermission" in sql + assert not is_massive_join, "Regression detected: Massive JOIN cross-product found in SQL shape" \ No newline at end of file From bb2999c4659891ef1386e88689dd3312208fe217 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 6 Aug 2026 10:48:54 +0000 Subject: [PATCH 14/26] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- api/tests/unit/users/test_unit_users_models.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/api/tests/unit/users/test_unit_users_models.py b/api/tests/unit/users/test_unit_users_models.py index c628f78b6b5a..3a7664b409d1 100644 --- a/api/tests/unit/users/test_unit_users_models.py +++ b/api/tests/unit/users/test_unit_users_models.py @@ -3,7 +3,9 @@ import pytest from common.projects.permissions import VIEW_PROJECT +from django.db import connection from django.db.utils import IntegrityError +from django.test.utils import CaptureQueriesContext from organisations.models import Organisation, OrganisationRole from organisations.permissions.models import UserOrganisationPermission @@ -15,8 +17,7 @@ FFAdminUser, UserPermissionGroup, ) -from django.db import connection -from django.test.utils import CaptureQueriesContext + def test_belongs_to__user_in_organisation__returns_true( admin_user: FFAdminUser, @@ -284,7 +285,7 @@ def test_user_has_organisation_permission__evaluating_permission__avoids_join_ex # Then assert has_permission is False - # The old regression executed exactly 1 massive query. + # The old regression executed exactly 1 massive query. # The new logic evaluates sequentially, executing 3 to 4 isolated queries in the worst case. assert len(ctx.captured_queries) > 1 @@ -294,4 +295,6 @@ def test_user_has_organisation_permission__evaluating_permission__avoids_join_ex for query in ctx.captured_queries: sql = query["sql"].lower() is_massive_join = "userpermission" in sql and "grouppermission" in sql - assert not is_massive_join, "Regression detected: Massive JOIN cross-product found in SQL shape" \ No newline at end of file + assert not is_massive_join, ( + "Regression detected: Massive JOIN cross-product found in SQL shape" + ) From 79ad30fccbf3722620e77e5719e44d482d5fc864 Mon Sep 17 00:00:00 2001 From: Srijan Tripathi Date: Thu, 6 Aug 2026 10:51:50 +0000 Subject: [PATCH 15/26] fix: import typing module to resolve name-defined mypy error --- api/tests/unit/users/test_unit_users_models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/tests/unit/users/test_unit_users_models.py b/api/tests/unit/users/test_unit_users_models.py index 3a7664b409d1..90a43f0a568f 100644 --- a/api/tests/unit/users/test_unit_users_models.py +++ b/api/tests/unit/users/test_unit_users_models.py @@ -17,7 +17,7 @@ FFAdminUser, UserPermissionGroup, ) - +import typing def test_belongs_to__user_in_organisation__returns_true( admin_user: FFAdminUser, From 3822e9b3eadccb83ec07edb50efeb3564ce8db34 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 6 Aug 2026 10:52:04 +0000 Subject: [PATCH 16/26] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- api/tests/unit/users/test_unit_users_models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/tests/unit/users/test_unit_users_models.py b/api/tests/unit/users/test_unit_users_models.py index 90a43f0a568f..3a7664b409d1 100644 --- a/api/tests/unit/users/test_unit_users_models.py +++ b/api/tests/unit/users/test_unit_users_models.py @@ -17,7 +17,7 @@ FFAdminUser, UserPermissionGroup, ) -import typing + def test_belongs_to__user_in_organisation__returns_true( admin_user: FFAdminUser, From d4de26a712106f28b816be049f9ec42fc03b07fc Mon Sep 17 00:00:00 2001 From: Srijan Tripathi Date: Thu, 6 Aug 2026 11:16:53 +0000 Subject: [PATCH 17/26] fix: import typing module --- api/permissions/permission_service.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/permissions/permission_service.py b/api/permissions/permission_service.py index 57a367500f6b..7e94ef84be47 100644 --- a/api/permissions/permission_service.py +++ b/api/permissions/permission_service.py @@ -7,7 +7,7 @@ from organisations.models import Organisation, OrganisationRole from projects.models import Project from telemetry.spans import set_span_attribute - +import typing from .rbac_wrapper import ( # type: ignore[attr-defined] get_permitted_environments_for_master_api_key_using_roles, get_permitted_projects_for_master_api_key_using_roles, From 26c8637b0ba8800eb816491f12b339191e7d83f6 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 6 Aug 2026 11:17:26 +0000 Subject: [PATCH 18/26] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- api/permissions/permission_service.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/api/permissions/permission_service.py b/api/permissions/permission_service.py index 7e94ef84be47..6a0a28e263b3 100644 --- a/api/permissions/permission_service.py +++ b/api/permissions/permission_service.py @@ -1,3 +1,4 @@ +import typing from typing import TYPE_CHECKING, List, Set, Union from django.conf import settings @@ -7,7 +8,7 @@ from organisations.models import Organisation, OrganisationRole from projects.models import Project from telemetry.spans import set_span_attribute -import typing + from .rbac_wrapper import ( # type: ignore[attr-defined] get_permitted_environments_for_master_api_key_using_roles, get_permitted_projects_for_master_api_key_using_roles, From 901a942003ace0cd0dfca9133b13807cd59388cf Mon Sep 17 00:00:00 2001 From: Srijan Tripathi Date: Fri, 7 Aug 2026 06:03:07 +0000 Subject: [PATCH 19/26] test: move test to dedicated module and enforce exact query count --- .../test_user_has_organisation_permissions.py | 23 ++++++++++++- .../unit/users/test_unit_users_models.py | 34 ------------------- 2 files changed, 22 insertions(+), 35 deletions(-) diff --git a/api/tests/unit/permissions/permission_service/test_user_has_organisation_permissions.py b/api/tests/unit/permissions/permission_service/test_user_has_organisation_permissions.py index 1798892cc473..c4197cc9652b 100644 --- a/api/tests/unit/permissions/permission_service/test_user_has_organisation_permissions.py +++ b/api/tests/unit/permissions/permission_service/test_user_has_organisation_permissions.py @@ -11,7 +11,7 @@ from permissions.models import PermissionModel from permissions.permission_service import user_has_organisation_permission from users.models import FFAdminUser, UserPermissionGroup - +import typing def test_user_has_organisation_permission__no_permissions_assigned__returns_false( staff_user: FFAdminUser, @@ -156,3 +156,24 @@ def test_user_has_organisation_permission__user_removed_from_organisation__retur organisation=organisation, permission_key=CREATE_PROJECT, ) + + +def test_user_has_organisation_permission__evaluating_permission__executes_exact_queries( + django_assert_num_queries: typing.Any, + django_user_model: typing.Any, + organisation: typing.Any, +) -> None: + # Given + user = django_user_model.objects.create(email="test_sequential_eval@example.com") + user.add_organisation(organisation) + + # When / Then + # Using an exact query count verifies the sequential EXISTS pattern is working. + # If the regression returns (a single massive JOIN cross-product), it will execute + # as 1 query and fail this strict assertion. + with django_assert_num_queries(4): + has_permission = user_has_organisation_permission( + user=user, organisation=organisation, permission_key="MANAGE_USER_GROUPS" + ) + + assert has_permission is False \ No newline at end of file diff --git a/api/tests/unit/users/test_unit_users_models.py b/api/tests/unit/users/test_unit_users_models.py index 3a7664b409d1..cd43336b20cb 100644 --- a/api/tests/unit/users/test_unit_users_models.py +++ b/api/tests/unit/users/test_unit_users_models.py @@ -3,9 +3,7 @@ import pytest from common.projects.permissions import VIEW_PROJECT -from django.db import connection from django.db.utils import IntegrityError -from django.test.utils import CaptureQueriesContext from organisations.models import Organisation, OrganisationRole from organisations.permissions.models import UserOrganisationPermission @@ -266,35 +264,3 @@ def test_email_domain__valid_email__returns_domain(): # type: ignore[no-untyped assert FFAdminUser(email="test@example.com").email_domain == "example.com" -@pytest.mark.django_db -@pytest.mark.django_db -def test_user_has_organisation_permission__evaluating_permission__avoids_join_explosion( - django_user_model: typing.Any, - organisation: typing.Any, -) -> None: - # Given - user = django_user_model.objects.create(email="test_query_count@example.com") - user.add_organisation(organisation) - - # When - with CaptureQueriesContext(connection) as ctx: - has_permission = user_has_organisation_permission( - user=user, organisation=organisation, permission_key="MANAGE_USER_GROUPS" - ) - - # Then - assert has_permission is False - - # The old regression executed exactly 1 massive query. - # The new logic evaluates sequentially, executing 3 to 4 isolated queries in the worst case. - assert len(ctx.captured_queries) > 1 - - # Guard against the SQL shape regression: - # The expensive join cross-product evaluated user, group, and role permissions in a single statement. - # We verify that no single executed query attempts to join multiple permission tables together. - for query in ctx.captured_queries: - sql = query["sql"].lower() - is_massive_join = "userpermission" in sql and "grouppermission" in sql - assert not is_massive_join, ( - "Regression detected: Massive JOIN cross-product found in SQL shape" - ) From b048a4a9ed850647d3e0b990197405e283dc900b Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 7 Aug 2026 06:03:27 +0000 Subject: [PATCH 20/26] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- .../test_user_has_organisation_permissions.py | 8 +++++--- api/tests/unit/users/test_unit_users_models.py | 4 ---- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/api/tests/unit/permissions/permission_service/test_user_has_organisation_permissions.py b/api/tests/unit/permissions/permission_service/test_user_has_organisation_permissions.py index c4197cc9652b..a89d58ae9964 100644 --- a/api/tests/unit/permissions/permission_service/test_user_has_organisation_permissions.py +++ b/api/tests/unit/permissions/permission_service/test_user_has_organisation_permissions.py @@ -1,3 +1,5 @@ +import typing + from organisations.models import Organisation, UserOrganisation from organisations.permissions.models import ( OrganisationPermissionModel, @@ -11,7 +13,7 @@ from permissions.models import PermissionModel from permissions.permission_service import user_has_organisation_permission from users.models import FFAdminUser, UserPermissionGroup -import typing + def test_user_has_organisation_permission__no_permissions_assigned__returns_false( staff_user: FFAdminUser, @@ -169,11 +171,11 @@ def test_user_has_organisation_permission__evaluating_permission__executes_exact # When / Then # Using an exact query count verifies the sequential EXISTS pattern is working. - # If the regression returns (a single massive JOIN cross-product), it will execute + # If the regression returns (a single massive JOIN cross-product), it will execute # as 1 query and fail this strict assertion. with django_assert_num_queries(4): has_permission = user_has_organisation_permission( user=user, organisation=organisation, permission_key="MANAGE_USER_GROUPS" ) - assert has_permission is False \ No newline at end of file + assert has_permission is False diff --git a/api/tests/unit/users/test_unit_users_models.py b/api/tests/unit/users/test_unit_users_models.py index cd43336b20cb..e95e622d62dc 100644 --- a/api/tests/unit/users/test_unit_users_models.py +++ b/api/tests/unit/users/test_unit_users_models.py @@ -1,4 +1,3 @@ -import typing import uuid import pytest @@ -8,7 +7,6 @@ from organisations.models import Organisation, OrganisationRole from organisations.permissions.models import UserOrganisationPermission from organisations.permissions.permissions import ORGANISATION_PERMISSIONS -from permissions.permission_service import user_has_organisation_permission from projects.models import Project from tests.types import WithProjectPermissionsCallable from users.models import ( @@ -262,5 +260,3 @@ def test_email_domain__valid_email__returns_domain(): # type: ignore[no-untyped # Given / When # Then assert FFAdminUser(email="test@example.com").email_domain == "example.com" - - From 1d4b857c966372dca9d278e6a4ce9428815b5c8d Mon Sep 17 00:00:00 2001 From: Srijan Tripathi Date: Fri, 7 Aug 2026 06:05:07 +0000 Subject: [PATCH 21/26] test: adjust exact query count to 3 due to disabled RBAC in test env --- .../test_user_has_organisation_permissions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/tests/unit/permissions/permission_service/test_user_has_organisation_permissions.py b/api/tests/unit/permissions/permission_service/test_user_has_organisation_permissions.py index a89d58ae9964..3461dd6c1a33 100644 --- a/api/tests/unit/permissions/permission_service/test_user_has_organisation_permissions.py +++ b/api/tests/unit/permissions/permission_service/test_user_has_organisation_permissions.py @@ -173,7 +173,7 @@ def test_user_has_organisation_permission__evaluating_permission__executes_exact # Using an exact query count verifies the sequential EXISTS pattern is working. # If the regression returns (a single massive JOIN cross-product), it will execute # as 1 query and fail this strict assertion. - with django_assert_num_queries(4): + with django_assert_num_queries(3): has_permission = user_has_organisation_permission( user=user, organisation=organisation, permission_key="MANAGE_USER_GROUPS" ) From f86cf3ffc1c719245b35ccc5ca2b913fcffc81e7 Mon Sep 17 00:00:00 2001 From: Srijan Tripathi Date: Fri, 7 Aug 2026 16:45:00 +0000 Subject: [PATCH 22/26] test: assert exact SQL shape and table names in query count test --- .../test_user_has_organisation_permissions.py | 21 +++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/api/tests/unit/permissions/permission_service/test_user_has_organisation_permissions.py b/api/tests/unit/permissions/permission_service/test_user_has_organisation_permissions.py index 3461dd6c1a33..92be145989fd 100644 --- a/api/tests/unit/permissions/permission_service/test_user_has_organisation_permissions.py +++ b/api/tests/unit/permissions/permission_service/test_user_has_organisation_permissions.py @@ -159,7 +159,6 @@ def test_user_has_organisation_permission__user_removed_from_organisation__retur permission_key=CREATE_PROJECT, ) - def test_user_has_organisation_permission__evaluating_permission__executes_exact_queries( django_assert_num_queries: typing.Any, django_user_model: typing.Any, @@ -169,13 +168,23 @@ def test_user_has_organisation_permission__evaluating_permission__executes_exact user = django_user_model.objects.create(email="test_sequential_eval@example.com") user.add_organisation(organisation) - # When / Then - # Using an exact query count verifies the sequential EXISTS pattern is working. - # If the regression returns (a single massive JOIN cross-product), it will execute - # as 1 query and fail this strict assertion. - with django_assert_num_queries(3): + # When + with django_assert_num_queries(3) as ctx: has_permission = user_has_organisation_permission( user=user, organisation=organisation, permission_key="MANAGE_USER_GROUPS" ) + # Then assert has_permission is False + + # Verify the exact queries executed match the expected sequential EXISTS pattern + queries = [query["sql"].lower() for query in ctx.captured_queries] + + # Query 1: Base user organisation role check + assert "organisations_userorganisation" in queries[0] + + # Query 2: User-specific permission check + assert "organisation_permissions_userorganisationpermission" in queries[1] + + # Query 3: Group-specific permission check + assert "organisation_permissions_userpermissiongroup" in queries[2] \ No newline at end of file From 83d835e57a87b3cafc42d707f20f448b6a174de0 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 7 Aug 2026 16:45:17 +0000 Subject: [PATCH 23/26] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- .../test_user_has_organisation_permissions.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/api/tests/unit/permissions/permission_service/test_user_has_organisation_permissions.py b/api/tests/unit/permissions/permission_service/test_user_has_organisation_permissions.py index 92be145989fd..84f883e2660c 100644 --- a/api/tests/unit/permissions/permission_service/test_user_has_organisation_permissions.py +++ b/api/tests/unit/permissions/permission_service/test_user_has_organisation_permissions.py @@ -159,6 +159,7 @@ def test_user_has_organisation_permission__user_removed_from_organisation__retur permission_key=CREATE_PROJECT, ) + def test_user_has_organisation_permission__evaluating_permission__executes_exact_queries( django_assert_num_queries: typing.Any, django_user_model: typing.Any, @@ -179,12 +180,12 @@ def test_user_has_organisation_permission__evaluating_permission__executes_exact # Verify the exact queries executed match the expected sequential EXISTS pattern queries = [query["sql"].lower() for query in ctx.captured_queries] - + # Query 1: Base user organisation role check assert "organisations_userorganisation" in queries[0] - + # Query 2: User-specific permission check assert "organisation_permissions_userorganisationpermission" in queries[1] - + # Query 3: Group-specific permission check - assert "organisation_permissions_userpermissiongroup" in queries[2] \ No newline at end of file + assert "organisation_permissions_userpermissiongroup" in queries[2] From affae1015e9adb29268ae6d47f89ded4db4ee89b Mon Sep 17 00:00:00 2001 From: Srijan Tripathi Date: Mon, 10 Aug 2026 19:38:46 +0530 Subject: [PATCH 24/26] fix: use fail-fast sequential queries for org permissions Co-authored-by: Gagan --- .../test_user_has_organisation_permissions.py | 97 +++++++++++++++---- 1 file changed, 80 insertions(+), 17 deletions(-) diff --git a/api/tests/unit/permissions/permission_service/test_user_has_organisation_permissions.py b/api/tests/unit/permissions/permission_service/test_user_has_organisation_permissions.py index 84f883e2660c..1706fbe64420 100644 --- a/api/tests/unit/permissions/permission_service/test_user_has_organisation_permissions.py +++ b/api/tests/unit/permissions/permission_service/test_user_has_organisation_permissions.py @@ -160,32 +160,95 @@ def test_user_has_organisation_permission__user_removed_from_organisation__retur ) -def test_user_has_organisation_permission__evaluating_permission__executes_exact_queries( +def test_user_has_organisation_permission__direct_permission__short_circuits_in_three_queries( + staff_user: FFAdminUser, + organisation: Organisation, django_assert_num_queries: typing.Any, - django_user_model: typing.Any, - organisation: typing.Any, ) -> None: # Given - user = django_user_model.objects.create(email="test_sequential_eval@example.com") - user.add_organisation(organisation) + user_org_permission = UserOrganisationPermission.objects.create( + user=staff_user, organisation=organisation + ) + user_org_permission.permissions.add(CREATE_PROJECT) # type: ignore[arg-type] # When - with django_assert_num_queries(3) as ctx: - has_permission = user_has_organisation_permission( - user=user, organisation=organisation, permission_key="MANAGE_USER_GROUPS" + # Should take only 3 queries: + # 1. Check if user is org admin (is_user_organisation_admin) + # 2. Check organisation membership + # 3. Check direct user permission (short-circuits here) + with django_assert_num_queries(3): + result = user_has_organisation_permission( + staff_user, organisation, CREATE_PROJECT ) # Then - assert has_permission is False + assert result is True + + +def test_user_has_organisation_permission__group_permission__short_circuits_in_four_queries( + staff_user: FFAdminUser, + organisation: Organisation, + user_permission_group: UserPermissionGroup, + django_assert_num_queries: typing.Any, +) -> None: + # Given + user_permission_group.users.add(staff_user) + group_org_permission = UserPermissionGroupOrganisationPermission.objects.create( + group=user_permission_group, organisation=organisation + ) + group_org_permission.permissions.add(CREATE_PROJECT) # type: ignore[arg-type] - # Verify the exact queries executed match the expected sequential EXISTS pattern - queries = [query["sql"].lower() for query in ctx.captured_queries] + # When + # Should take only 4 queries: + # 1. Check if user is org admin (is_user_organisation_admin) + # 2. Check organisation membership + # 3. Check direct user permission (not found) + # 4. Check group permission (short-circuits here) + with django_assert_num_queries(4): + result = user_has_organisation_permission( + staff_user, organisation, CREATE_PROJECT + ) - # Query 1: Base user organisation role check - assert "organisations_userorganisation" in queries[0] + # Then + assert result is True - # Query 2: User-specific permission check - assert "organisation_permissions_userorganisationpermission" in queries[1] - # Query 3: Group-specific permission check - assert "organisation_permissions_userpermissiongroup" in queries[2] +def test_user_has_organisation_permission__no_permissions_assigned__checks_each_source_in_four_queries( + staff_user: FFAdminUser, + organisation: Organisation, + django_assert_num_queries: typing.Any, +) -> None: + # Given / When + # Should take exactly 4 queries, one per permission source — never a + # single combined query joining the user and group permission tables: + # 1. Check if user is org admin (is_user_organisation_admin) + # 2. Check organisation membership + # 3. Check direct user permission (not found) + # 4. Check group permission (not found; role check skipped without RBAC) + with django_assert_num_queries(4): + result = user_has_organisation_permission( + staff_user, organisation, MANAGE_USER_GROUPS + ) + + # Then + assert result is False + + +def test_user_has_organisation_permission__user_not_in_organisation__short_circuits_in_two_queries( + organisation: Organisation, + django_assert_num_queries: typing.Any, +) -> None: + # Given + user = FFAdminUser.objects.create(email="not-a-member@example.com") + + # When + # Should take only 2 queries: + # 1. Check if user is org admin (is_user_organisation_admin) + # 2. Check organisation membership (short-circuits here) + with django_assert_num_queries(2): + result = user_has_organisation_permission( + user, organisation, MANAGE_USER_GROUPS + ) + + # Then + assert result is False From ec3095d7a812d1722b429440f6b754b9d1fb3955 Mon Sep 17 00:00:00 2001 From: Srijan Tripathi Date: Mon, 10 Aug 2026 14:10:38 +0000 Subject: [PATCH 25/26] fix: apply sequential query implementation for org permissions --- api/permissions/permission_service.py | 42 +++++++++++++++++---------- 1 file changed, 26 insertions(+), 16 deletions(-) diff --git a/api/permissions/permission_service.py b/api/permissions/permission_service.py index 6a0a28e263b3..087d7f92d589 100644 --- a/api/permissions/permission_service.py +++ b/api/permissions/permission_service.py @@ -1,4 +1,3 @@ -import typing from typing import TYPE_CHECKING, List, Set, Union from django.conf import settings @@ -200,33 +199,44 @@ def get_permitted_environments_for_master_api_key( def user_has_organisation_permission( user: "FFAdminUser", organisation: Organisation, permission_key: str ) -> bool: + """ + Check if user has the given permission on an organisation. + + Runs separate queries with early returns: + 1. Organisation admin - admins hold every organisation permission. + 2. Organisation membership - check to prevent orphaned permission + records from granting access. + 3. Direct user permission - checks UserOrganisationPermission. + 4. Group permission - checks via user's group memberships. + 5. Role permission - RBAC check, only if enabled. + """ if is_user_organisation_admin(user, organisation): return True - # Base query to ensure the user actually belongs to the organisation - base_qs = Organisation.objects.filter(id=organisation.id, users=user) + # Check: verify user belongs to the organisation + if not Organisation.objects.filter(id=organisation.id, users=user).exists(): + return False - # 1. Check direct user permissions (Fastest) + # NOTE: since we store organisation admin slightly differently + # compared to project and environment, allow_admin=True will not + # work for organisation + + # Check direct permission user_filter = get_user_permission_filter(user, permission_key, allow_admin=False) - if base_qs.filter(user_filter).exists(): + if Organisation.objects.filter(user_filter & Q(id=organisation.id)).exists(): return True - # 2. Check group permissions + # Check group permission group_filter = get_group_permission_filter(user, permission_key, allow_admin=False) - if base_qs.filter(group_filter).exists(): + if Organisation.objects.filter(group_filter & Q(id=organisation.id)).exists(): return True - # 3. Check role permissions (only if RBAC is installed) + # Check role permission (only if RBAC installed) if settings.IS_RBAC_INSTALLED: # pragma: no cover role_filter = get_role_permission_filter( - user, - # Type gap: get_role_permission_filter type hint expects an instance, - # but safely handles the model class at runtime. - typing.cast(typing.Any, Organisation), - permission_key, - allow_admin=False, + user, Organisation, permission_key, allow_admin=False ) - if base_qs.filter(role_filter).exists(): + if Organisation.objects.filter(role_filter & Q(id=organisation.id)).exists(): return True return False @@ -368,4 +378,4 @@ def get_group_permission_filter( permission_filter = permission_filter | Q( grouppermission__permissions__key=permission_key ) - return base_filter & permission_filter + return base_filter & permission_filter \ No newline at end of file From a4db1fd9a0417493a594365dbe948ca48c1f7111 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 10 Aug 2026 14:10:55 +0000 Subject: [PATCH 26/26] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- api/permissions/permission_service.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/permissions/permission_service.py b/api/permissions/permission_service.py index 087d7f92d589..ad1ab41cb86f 100644 --- a/api/permissions/permission_service.py +++ b/api/permissions/permission_service.py @@ -378,4 +378,4 @@ def get_group_permission_filter( permission_filter = permission_filter | Q( grouppermission__permissions__key=permission_key ) - return base_filter & permission_filter \ No newline at end of file + return base_filter & permission_filter