From deb1ef9ed645ede6b69654fb22f1b9b36de9caf1 Mon Sep 17 00:00:00 2001 From: lebaudantoine Date: Wed, 23 Sep 2026 17:55:14 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9A=A1=EF=B8=8F(backend)=20reduce=20domain?= =?UTF-8?q?=20queries=20on=20the=20application=20token=20endpoint?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Load allowed domains once and compare the lowercased email domain in Python. For restricted applications with an existing user, this reduces successful token requests from four queries to three. Keep domain loading after credential validation so rejected credentials require only the application lookup. Add endpoint query-count tests for restricted and unrestricted successes and invalid credentials. --- CHANGELOG.md | 1 + src/backend/core/models.py | 5 +- .../core/tests/test_external_api_token.py | 54 +++++++++++++++++++ 3 files changed, 58 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 432303eda..bb65c5338 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to ### Changed - ⚡️(backend) hash application secrets with SHA-256 +- ⚡️(backend) reduce domain queries on the application token endpoint ### Fixed diff --git a/src/backend/core/models.py b/src/backend/core/models.py index 8d4d13a3e..211a248b3 100644 --- a/src/backend/core/models.py +++ b/src/backend/core/models.py @@ -903,11 +903,12 @@ class Application(BaseModel): def can_delegate_email(self, email): """Check if this application can delegate the given email.""" - if not self.allowed_domains.exists(): + allowed_domains = {d.domain for d in self.allowed_domains.all()} + if not allowed_domains: return True # No domain restrictions domain = get_domain_from_email(email) - return self.allowed_domains.filter(domain__iexact=domain).exists() + return bool(domain) and domain.lower() in allowed_domains class ApplicationDomain(BaseModel): diff --git a/src/backend/core/tests/test_external_api_token.py b/src/backend/core/tests/test_external_api_token.py index a4e151227..37036f797 100644 --- a/src/backend/core/tests/test_external_api_token.py +++ b/src/backend/core/tests/test_external_api_token.py @@ -87,6 +87,60 @@ def test_api_applications_generate_token_success(settings): } +@pytest.mark.parametrize( + "restricted", [True, False], ids=["restricted", "unrestricted"] +) +def test_api_applications_generate_token_success_query_count( + restricted, django_assert_num_queries +): + """An existing user needs one query each for application, domains, and user.""" + user = UserFactory(email="user@example.com") + plain_secret = "test-secret-123" + application = ApplicationFactory(client_secret=plain_secret) + if restricted: + ApplicationDomainFactory(application=application, domain="example.com") + + client = APIClient() + with django_assert_num_queries(3): + response = client.post( + "/external-api/v1.0/application/token/", + { + "client_id": application.client_id, + "client_secret": plain_secret, + "grant_type": "client_credentials", + "scope": user.email, + }, + format="json", + ) + + assert response.status_code == 200 + assert "access_token" in response.data + + +def test_api_applications_generate_token_invalid_credentials_query_count( + django_assert_num_queries, +): + """An invalid secret must be rejected before querying domains or users.""" + application = ApplicationFactory(client_secret="test-secret-123") + ApplicationDomainFactory(application=application, domain="example.com") + + client = APIClient() + with django_assert_num_queries(1): + response = client.post( + "/external-api/v1.0/application/token/", + { + "client_id": application.client_id, + "client_secret": "wrong-secret", + "grant_type": "client_credentials", + "scope": "user@example.com", + }, + format="json", + ) + + assert response.status_code == 401 + assert "Invalid credentials" in str(response.data) + + def test_api_applications_generate_token_form_urlencoded(settings): """The token endpoint should accept "application/x-www-form-urlencoded" requests, as mandated by RFC 6749 (sections 3.2 and 4.4.2) for OAuth 2.0