From eb6c827b611d4a4b13e08d53628676f05a6e5cf5 Mon Sep 17 00:00:00 2001 From: Soumya Ranjan Das Date: Mon, 22 Dec 2025 12:30:37 +0530 Subject: [PATCH 1/6] feat: Add rate limiting to identity search endpoint - Backend: Add ScopedRateThrottle to IdentityViewSet (30 req/min) - Frontend: Increase debounce from 500ms to 750ms - Config: Add IDENTITY_SEARCH_THROTTLE_RATE env variable - Tests: Add test for identity search throttling Fixes aggressive API requests when searching for identities. --- api/app/settings/common.py | 2 ++ api/environments/identities/views.py | 3 +++ .../identities/test_unit_identities_views.py | 22 +++++++++++++++++++ frontend/common/useDebouncedSearch.ts | 4 ++-- 4 files changed, 29 insertions(+), 2 deletions(-) diff --git a/api/app/settings/common.py b/api/app/settings/common.py index 478d7a0dd7dd..52f98dafead5 100644 --- a/api/app/settings/common.py +++ b/api/app/settings/common.py @@ -302,6 +302,7 @@ LOGIN_THROTTLE_RATE = env("LOGIN_THROTTLE_RATE", "20/min") SIGNUP_THROTTLE_RATE = env("SIGNUP_THROTTLE_RATE", "10000/min") USER_THROTTLE_RATE = env("USER_THROTTLE_RATE", "500/min") +IDENTITY_SEARCH_THROTTLE_RATE = env("IDENTITY_SEARCH_THROTTLE_RATE", "30/min") DEFAULT_THROTTLE_CLASSES = env.list("DEFAULT_THROTTLE_CLASSES", subcast=str, default=[]) REST_FRAMEWORK = { "DEFAULT_PERMISSION_CLASSES": ["rest_framework.permissions.IsAuthenticated"], @@ -320,6 +321,7 @@ "mfa_code": "5/min", "invite": "10/min", "user": USER_THROTTLE_RATE, + "identity_search": IDENTITY_SEARCH_THROTTLE_RATE, }, "DEFAULT_FILTER_BACKENDS": ["django_filters.rest_framework.DjangoFilterBackend"], "DEFAULT_RENDERER_CLASSES": [ diff --git a/api/environments/identities/views.py b/api/environments/identities/views.py index f108953f1be3..99419654348d 100644 --- a/api/environments/identities/views.py +++ b/api/environments/identities/views.py @@ -16,6 +16,7 @@ from rest_framework import status, viewsets from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response +from rest_framework.throttling import ScopedRateThrottle from app.pagination import CustomPagination from core.constants import FLAGSMITH_UPDATED_AT_HEADER, SDK_ENVIRONMENT_KEY_HEADER @@ -41,6 +42,8 @@ class IdentityViewSet(viewsets.ModelViewSet): # type: ignore[type-arg] serializer_class = IdentitySerializer pagination_class = CustomPagination + throttle_classes = [ScopedRateThrottle] + throttle_scope = "identity_search" def get_queryset(self): # type: ignore[no-untyped-def] if getattr(self, "swagger_fake_view", False): diff --git a/api/tests/unit/environments/identities/test_unit_identities_views.py b/api/tests/unit/environments/identities/test_unit_identities_views.py index dff9ed67bead..3f6775038938 100644 --- a/api/tests/unit/environments/identities/test_unit_identities_views.py +++ b/api/tests/unit/environments/identities/test_unit_identities_views.py @@ -305,6 +305,28 @@ def test_search_identities_still_allows_paging( assert response2.data["results"] +def test_identity_search_is_throttled( + admin_client: APIClient, + environment: Environment, + settings, +) -> None: + # Given - configure a very restrictive throttle rate for testing + settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["identity_search"] = "1/min" + base_url = reverse( + "api-v1:environments:environment-identities-list", + args=[environment.api_key], + ) + url = f"{base_url}?q=test" + + # When - make 2 requests in quick succession + response1 = admin_client.get(url) + response2 = admin_client.get(url) + + # Then - first should succeed, second should be throttled + assert response1.status_code == status.HTTP_200_OK + assert response2.status_code == status.HTTP_429_TOO_MANY_REQUESTS + + def test_can_delete_identity( environment: Environment, admin_client: APIClient, diff --git a/frontend/common/useDebouncedSearch.ts b/frontend/common/useDebouncedSearch.ts index afa82cde3e79..3d2de60014c7 100644 --- a/frontend/common/useDebouncedSearch.ts +++ b/frontend/common/useDebouncedSearch.ts @@ -4,10 +4,10 @@ import useDebounce from './useDebounce' export default function useDebouncedSearch(initialValue = '') { const [searchInput, setSearchInput] = useState(initialValue) const [search, setSearch] = useState(initialValue) - const [debounceTime, setDebounceTime] = useState(500) + const [debounceTime, setDebounceTime] = useState(750) useEffect(() => { - setDebounceTime(searchInput.length < 1 ? 0 : 500) + setDebounceTime(searchInput.length < 1 ? 0 : 750) }, [searchInput]) const debouncedSearch = useDebounce((value: string) => { From db1cc7770a6a8ee6a2df737b870a649351851dea Mon Sep 17 00:00:00 2001 From: Soumya Ranjan Das Date: Mon, 22 Dec 2025 13:04:44 +0530 Subject: [PATCH 2/6] fix: Apply identity search throttle only to list action - Use get_throttles() method to conditionally apply throttle only to 'list' action - Update test to use mocker.patch and reset_cache fixture for proper cleanup - Follow existing patterns from FFAdminUserViewSet and OrganisationViewSet --- api/environments/identities/views.py | 9 ++++++++- .../identities/test_unit_identities_views.py | 9 ++++++--- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/api/environments/identities/views.py b/api/environments/identities/views.py index 99419654348d..ba64cd25d348 100644 --- a/api/environments/identities/views.py +++ b/api/environments/identities/views.py @@ -42,9 +42,16 @@ class IdentityViewSet(viewsets.ModelViewSet): # type: ignore[type-arg] serializer_class = IdentitySerializer pagination_class = CustomPagination - throttle_classes = [ScopedRateThrottle] throttle_scope = "identity_search" + def get_throttles(self): # type: ignore[no-untyped-def] + """ + Apply identity_search throttle only to list (search) requests. + """ + if getattr(self, "action", None) == "list": + return [ScopedRateThrottle()] + return [] + def get_queryset(self): # type: ignore[no-untyped-def] if getattr(self, "swagger_fake_view", False): return Identity.objects.none() diff --git a/api/tests/unit/environments/identities/test_unit_identities_views.py b/api/tests/unit/environments/identities/test_unit_identities_views.py index 3f6775038938..1605434164aa 100644 --- a/api/tests/unit/environments/identities/test_unit_identities_views.py +++ b/api/tests/unit/environments/identities/test_unit_identities_views.py @@ -308,10 +308,13 @@ def test_search_identities_still_allows_paging( def test_identity_search_is_throttled( admin_client: APIClient, environment: Environment, - settings, + reset_cache: None, + mocker: MockerFixture, ) -> None: - # Given - configure a very restrictive throttle rate for testing - settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["identity_search"] = "1/min" + # Given - mock the throttle rate to be restrictive for testing + mocker.patch( + "rest_framework.throttling.ScopedRateThrottle.get_rate", return_value="1/minute" + ) base_url = reverse( "api-v1:environments:environment-identities-list", args=[environment.api_key], From aa244d51fe06de3f90fcd07cd92364271a816f6f Mon Sep 17 00:00:00 2001 From: Soumya Ranjan Das Date: Wed, 31 Dec 2025 16:49:40 +0530 Subject: [PATCH 3/6] fix: Add identity_search throttle rate to test settings The test.py settings file overrides DEFAULT_THROTTLE_RATES from common.py, so identity_search scope was missing in the test environment. --- api/app/settings/test.py | 1 + 1 file changed, 1 insertion(+) diff --git a/api/app/settings/test.py b/api/app/settings/test.py index a0018f64dac1..925568a12a72 100644 --- a/api/app/settings/test.py +++ b/api/app/settings/test.py @@ -11,6 +11,7 @@ "invite": "10/min", "signup": "100/min", "user": "100000/day", + "identity_search": "100/min", } AWS_SSE_LOGS_BUCKET_NAME = "test_bucket" From b17cd1a5ce9976f58f8fde9904133dc06806d9ca Mon Sep 17 00:00:00 2001 From: Soumya Ranjan Das Date: Tue, 20 Jan 2026 15:41:50 +0530 Subject: [PATCH 4/6] fix: Address reviewer feedback - Revert frontend debounce from 750ms back to 500ms (agreed UX time) - Return super().get_throttles() for non-list actions so global default throttle classes still apply --- api/environments/identities/views.py | 3 ++- frontend/common/useDebouncedSearch.ts | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/api/environments/identities/views.py b/api/environments/identities/views.py index ba64cd25d348..6ead69e3d9e6 100644 --- a/api/environments/identities/views.py +++ b/api/environments/identities/views.py @@ -47,10 +47,11 @@ class IdentityViewSet(viewsets.ModelViewSet): # type: ignore[type-arg] def get_throttles(self): # type: ignore[no-untyped-def] """ Apply identity_search throttle only to list (search) requests. + For other actions, return the global default throttle classes. """ if getattr(self, "action", None) == "list": return [ScopedRateThrottle()] - return [] + return super().get_throttles() def get_queryset(self): # type: ignore[no-untyped-def] if getattr(self, "swagger_fake_view", False): diff --git a/frontend/common/useDebouncedSearch.ts b/frontend/common/useDebouncedSearch.ts index 3d2de60014c7..afa82cde3e79 100644 --- a/frontend/common/useDebouncedSearch.ts +++ b/frontend/common/useDebouncedSearch.ts @@ -4,10 +4,10 @@ import useDebounce from './useDebounce' export default function useDebouncedSearch(initialValue = '') { const [searchInput, setSearchInput] = useState(initialValue) const [search, setSearch] = useState(initialValue) - const [debounceTime, setDebounceTime] = useState(750) + const [debounceTime, setDebounceTime] = useState(500) useEffect(() => { - setDebounceTime(searchInput.length < 1 ? 0 : 750) + setDebounceTime(searchInput.length < 1 ? 0 : 500) }, [searchInput]) const debouncedSearch = useDebounce((value: string) => { From 761d0a86f1f054ece6de1eb778ae32bd5ca6d218 Mon Sep 17 00:00:00 2001 From: wadii Date: Tue, 20 Jan 2026 15:04:07 +0100 Subject: [PATCH 5/6] feat: re-added-master-api-key-throttle --- api/app/settings/common.py | 1 + 1 file changed, 1 insertion(+) diff --git a/api/app/settings/common.py b/api/app/settings/common.py index 3df8b436c835..5622d280cedb 100644 --- a/api/app/settings/common.py +++ b/api/app/settings/common.py @@ -303,6 +303,7 @@ LOGIN_THROTTLE_RATE = env("LOGIN_THROTTLE_RATE", "20/min") SIGNUP_THROTTLE_RATE = env("SIGNUP_THROTTLE_RATE", "10000/min") USER_THROTTLE_RATE = env("USER_THROTTLE_RATE", "500/min") +MASTER_API_KEY_THROTTLE_RATE = env("MASTER_API_KEY_THROTTLE_RATE", USER_THROTTLE_RATE) IDENTITY_SEARCH_THROTTLE_RATE = env("IDENTITY_SEARCH_THROTTLE_RATE", "30/min") DEFAULT_THROTTLE_CLASSES = env.list("DEFAULT_THROTTLE_CLASSES", subcast=str, default=[]) REST_FRAMEWORK = { From ba7ab9cee7580185080a30955f6d632d924ba258 Mon Sep 17 00:00:00 2001 From: wadii Date: Tue, 20 Jan 2026 15:13:07 +0100 Subject: [PATCH 6/6] feat: re-added-deleted-values --- api/app/settings/common.py | 1 + api/app/settings/test.py | 2 ++ 2 files changed, 3 insertions(+) diff --git a/api/app/settings/common.py b/api/app/settings/common.py index 5622d280cedb..d528f9fdc87d 100644 --- a/api/app/settings/common.py +++ b/api/app/settings/common.py @@ -324,6 +324,7 @@ "mfa_code": "5/min", "invite": "10/min", "user": USER_THROTTLE_RATE, + "influx_query": "5/min", "identity_search": IDENTITY_SEARCH_THROTTLE_RATE, }, "DEFAULT_FILTER_BACKENDS": ["django_filters.rest_framework.DjangoFilterBackend"], diff --git a/api/app/settings/test.py b/api/app/settings/test.py index 925568a12a72..cf3cf3c24f10 100644 --- a/api/app/settings/test.py +++ b/api/app/settings/test.py @@ -11,6 +11,8 @@ "invite": "10/min", "signup": "100/min", "user": "100000/day", + "master_api_key": "100000/day", + "influx_query": "50/min", "identity_search": "100/min", }