diff --git a/authentik/core/api/applications.py b/authentik/core/api/applications.py index e963f94568..f63266c070 100644 --- a/authentik/core/api/applications.py +++ b/authentik/core/api/applications.py @@ -4,7 +4,7 @@ from collections.abc import Iterator from copy import copy from django.core.cache import cache -from django.db.models import Case, QuerySet +from django.db.models import Case, Q, QuerySet from django.db.models.expressions import When from django.shortcuts import get_object_or_404 from django.utils.translation import gettext as _ @@ -36,9 +36,13 @@ from authentik.rbac.filters import ObjectFilter LOGGER = get_logger() -def user_app_cache_key(user_pk: str, page_number: int | None = None) -> str: +def user_app_cache_key( + user_pk: str, page_number: int | None = None, only_with_launch_url: bool = False +) -> str: """Cache key where application list for user is saved""" key = f"{CACHE_PREFIX}app_access/{user_pk}" + if only_with_launch_url: + key += "/launch" if page_number: key += f"/{page_number}" return key @@ -279,6 +283,14 @@ class ApplicationViewSet(UsedByMixin, ModelViewSet): ).lower() queryset = self._filter_queryset_for_list(self.get_queryset()) + if only_with_launch_url == "true": + # Pre-filter at DB level to skip expensive per-app policy evaluation + # for apps that can never appear in the launcher: + # - No meta_launch_url AND no provider: no possible launch URL + # - meta_launch_url="blank://blank": documented convention to hide from launcher + queryset = queryset.exclude( + Q(meta_launch_url="", provider__isnull=True) | Q(meta_launch_url="blank://blank") + ) paginator: Pagination = self.paginator paginated_apps = paginator.paginate_queryset(queryset, request) @@ -295,7 +307,6 @@ class ApplicationViewSet(UsedByMixin, ModelViewSet): except ValueError as exc: raise ValidationError from exc allowed_applications = self._get_allowed_applications(paginated_apps, user=for_user) - allowed_applications = self._expand_applications(allowed_applications) serializer = self.get_serializer(allowed_applications, many=True) return self.get_paginated_response(serializer.data) @@ -305,17 +316,24 @@ class ApplicationViewSet(UsedByMixin, ModelViewSet): allowed_applications = self._get_allowed_applications(paginated_apps) if should_cache: allowed_applications = cache.get( - user_app_cache_key(self.request.user.pk, paginator.page.number) + user_app_cache_key( + self.request.user.pk, paginator.page.number, only_with_launch_url == "true" + ) ) - if not allowed_applications: + if allowed_applications: + # Re-fetch cached applications since pickled instances lose prefetched + # relationships, causing N+1 queries during serialization + allowed_applications = self._expand_applications(allowed_applications) + else: LOGGER.debug("Caching allowed application list", page=paginator.page.number) allowed_applications = self._get_allowed_applications(paginated_apps) cache.set( - user_app_cache_key(self.request.user.pk, paginator.page.number), + user_app_cache_key( + self.request.user.pk, paginator.page.number, only_with_launch_url == "true" + ), allowed_applications, timeout=86400, ) - allowed_applications = self._expand_applications(allowed_applications) if only_with_launch_url == "true": allowed_applications = self._filter_applications_with_launch_url(allowed_applications) diff --git a/authentik/core/models.py b/authentik/core/models.py index 4043baa9b8..be8421a6d2 100644 --- a/authentik/core/models.py +++ b/authentik/core/models.py @@ -790,9 +790,13 @@ class Application(SerializerModel, PolicyBindingModel): def get_provider(self) -> Provider | None: """Get casted provider instance. Needs Application queryset with_provider""" + if hasattr(self, "_cached_provider"): + return self._cached_provider if not self.provider: + self._cached_provider = None return None - return get_deepest_child(self.provider) + self._cached_provider = get_deepest_child(self.provider) + return self._cached_provider def backchannel_provider_for[T: Provider](self, provider_type: type[T], **kwargs) -> T | None: """Get Backchannel provider for a specific type"""