Skip to content

Commit b8197e7

Browse files
committed
attempt at optimizing applications api for users with a lot of groups and access to a lot of applications
1 parent f66c535 commit b8197e7

File tree

2 files changed

+67
-4
lines changed

2 files changed

+67
-4
lines changed

authentik/core/api/applications.py

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from copy import copy
55

66
from django.core.cache import cache
7+
from django.db import models
78
from django.db.models import QuerySet
89
from django.shortcuts import get_object_or_404
910
from django.utils.translation import gettext as _
@@ -63,9 +64,26 @@ class ApplicationSerializer(ModelSerializer):
6364
def get_launch_url(self, app: Application) -> str | None:
6465
"""Allow formatting of launch URL"""
6566
user = None
67+
user_data = None
68+
6669
if "request" in self.context:
6770
user = self.context["request"].user
68-
return app.get_launch_url(user)
71+
72+
# Cache serialized user data to avoid N+1 when formatting launch URLs
73+
# for multiple applications. UserSerializer accesses user.ak_groups which
74+
# would otherwise trigger a query for each application.
75+
if user and user.is_authenticated:
76+
if "_cached_user_data" not in self.context:
77+
# Prefetch groups to avoid N+1
78+
from django.db.models import prefetch_related_objects
79+
80+
from authentik.core.api.users import UserSerializer
81+
82+
prefetch_related_objects([user], "ak_groups")
83+
self.context["_cached_user_data"] = UserSerializer(instance=user).data
84+
user_data = self.context["_cached_user_data"]
85+
86+
return app.get_launch_url(user, user_data=user_data)
6987

7088
def validate_slug(self, slug: str) -> str:
7189
if slug in Application.reserved_slugs:
@@ -262,6 +280,20 @@ def list(self, request: Request) -> Response:
262280
except ValueError as exc:
263281
raise ValidationError from exc
264282
allowed_applications = self._get_allowed_applications(paginated_apps, user=for_user)
283+
284+
# Re-fetch with proper prefetching for serialization
285+
if allowed_applications:
286+
app_pks = [app.pk for app in allowed_applications]
287+
allowed_applications = list(
288+
self.get_queryset()
289+
.filter(pk__in=app_pks)
290+
.order_by(
291+
models.Case(
292+
*[models.When(pk=pk, then=pos) for pos, pk in enumerate(app_pks)]
293+
)
294+
)
295+
)
296+
265297
serializer = self.get_serializer(allowed_applications, many=True)
266298
return self.get_paginated_response(serializer.data)
267299

@@ -284,6 +316,20 @@ def list(self, request: Request) -> Response:
284316
if only_with_launch_url == "true":
285317
allowed_applications = self._filter_applications_with_launch_url(allowed_applications)
286318

319+
# Re-fetch applications with proper prefetching for serialization
320+
# Cached applications don't have prefetched relationships, causing N+1 queries
321+
# during serialization when get_provider() is called
322+
if allowed_applications:
323+
app_pks = [app.pk for app in allowed_applications]
324+
allowed_applications = list(
325+
self.get_queryset()
326+
.filter(pk__in=app_pks)
327+
.order_by(
328+
# Preserve the original order from policy evaluation
329+
models.Case(*[models.When(pk=pk, then=pos) for pos, pk in enumerate(app_pks)])
330+
)
331+
)
332+
287333
serializer = self.get_serializer(allowed_applications, many=True)
288334
return self.get_paginated_response(serializer.data)
289335

authentik/core/models.py

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -524,6 +524,13 @@ def with_provider(self) -> "QuerySet[Application]":
524524
qs = self.select_related("provider")
525525
for subclass in Provider.objects.get_queryset()._get_subclasses_recurse(Provider):
526526
qs = qs.select_related(f"provider__{subclass}")
527+
# Also prefetch/select through each subclass path to ensure casted instances have access
528+
qs = qs.prefetch_related(f"provider__{subclass}__property_mappings")
529+
qs = qs.select_related(f"provider__{subclass}__application")
530+
# qs = qs.select_related(f"provider__{subclass}__backchannel_application")
531+
# qs = qs.select_related(f"provider__{subclass}__authentication_flow")
532+
# qs = qs.select_related(f"provider__{subclass}__authorization_flow")
533+
# qs = qs.select_related(f"provider__{subclass}__invalidation_flow")
527534
return qs
528535

529536

@@ -583,8 +590,15 @@ def get_meta_icon(self) -> str | None:
583590
return CONFIG.get("web.path", "/")[:-1] + self.meta_icon.name
584591
return self.meta_icon.url
585592

586-
def get_launch_url(self, user: Optional["User"] = None) -> str | None:
587-
"""Get launch URL if set, otherwise attempt to get launch URL based on provider."""
593+
def get_launch_url(
594+
self, user: Optional["User"] = None, user_data: dict | None = None
595+
) -> str | None:
596+
"""Get launch URL if set, otherwise attempt to get launch URL based on provider.
597+
598+
Args:
599+
user: User instance for formatting the URL
600+
user_data: Pre-serialized user data to avoid re-serialization (performance optimization)
601+
"""
588602
from authentik.core.api.users import UserSerializer
589603

590604
url = None
@@ -594,7 +608,10 @@ def get_launch_url(self, user: Optional["User"] = None) -> str | None:
594608
url = provider.launch_url
595609
if user and url:
596610
try:
597-
return url % UserSerializer(instance=user).data
611+
# Use pre-serialized data if available, otherwise serialize now
612+
if user_data is None:
613+
user_data = UserSerializer(instance=user).data
614+
return url % user_data
598615
except Exception as exc: # noqa
599616
LOGGER.warning("Failed to format launch url", exc=exc)
600617
return url

0 commit comments

Comments
 (0)