Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions authentik/admin/api/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@

from authentik.admin.flags import Flag
from authentik.admin.models import SystemSettings
from authentik.admin.utils import get_system_settings, normalize_base_url
from authentik.admin.utils import normalize_base_url
from authentik.core.api.utils import JSONDictField, ModelSerializer
from authentik.rbac.permissions import HasPermission

Expand Down Expand Up @@ -122,6 +122,7 @@ def get_permissions(self):
]

def get_object(self):
obj = get_system_settings()
# Fresh instance; the serializer mutates it on update
obj = SystemSettings.objects.get(pk=True)
self.check_object_permissions(self.request, obj)
return obj
3 changes: 1 addition & 2 deletions authentik/admin/flags.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ def get(cls, settings: SystemSettings | None = None) -> T | None:
@classmethod
def set(cls, value: T, settings: SystemSettings | None = None) -> T | None:
from authentik.admin.models import SystemSettings
from authentik.admin.utils import clear_system_settings_cache, get_system_settings
from authentik.admin.utils import get_system_settings

if not settings:
settings = get_system_settings()
Expand All @@ -59,7 +59,6 @@ def set(cls, value: T, settings: SystemSettings | None = None) -> T | None:
function="jsonb_set",
)
)
clear_system_settings_cache()

def get_default(self) -> T | None:
return self.default
Expand Down
18 changes: 0 additions & 18 deletions authentik/admin/middleware.py

This file was deleted.

12 changes: 12 additions & 0 deletions authentik/admin/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,23 @@
DEFAULT_REPUTATION_UPPER_LIMIT = 5


class SystemSettingsQuerySet(models.QuerySet):
def update(self, **kwargs) -> int:
# Queryset updates skip post_save, so drop the cached settings here too
from authentik.admin.utils import get_system_settings

rows = super().update(**kwargs)
get_system_settings.cache_clear()
return rows


class SystemSettings(InternallyManagedMixin, SerializerModel):
"""Global settings for this authentik instance"""

id = models.BooleanField(primary_key=True, default=True)

objects = SystemSettingsQuerySet.as_manager()

avatars = models.TextField(
help_text=_("Configure how authentik should show avatars for users."),
default="gravatar,initials",
Expand Down
4 changes: 2 additions & 2 deletions authentik/admin/signals.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

from authentik.admin.models import SystemSettings
from authentik.admin.tasks import _set_prom_info
from authentik.admin.utils import clear_system_settings_cache
from authentik.admin.utils import get_system_settings
from authentik.root.signals import post_startup


Expand All @@ -14,4 +14,4 @@ def post_startup_admin_metrics(sender, **_):

@receiver(post_save, sender=SystemSettings)
def system_settings_saved(**_):
clear_system_settings_cache()
get_system_settings.cache_clear()
30 changes: 9 additions & 21 deletions authentik/admin/tests/test_system_settings_cache.py
Original file line number Diff line number Diff line change
@@ -1,32 +1,20 @@
from django.http import HttpRequest, HttpResponse
from django.test import RequestFactory, TestCase
from django.test import TestCase

from authentik.admin.middleware import SystemSettingsMiddleware
from authentik.admin.models import SystemSettings
from authentik.admin.utils import get_system_settings


class TestSystemSettingsCache(TestCase):
def test_scoped_to_request(self):
"""Settings are only cached within a single request"""
seen = []
def test_cached(self):
"""Settings are cached across calls"""
self.assertIs(get_system_settings(), get_system_settings())

def view(request: HttpRequest) -> HttpResponse:
seen.extend([get_system_settings(), get_system_settings()])
return HttpResponse()

middleware = SystemSettingsMiddleware(view)
middleware(RequestFactory().get("/"))
middleware(RequestFactory().get("/"))
self.assertIs(seen[0], seen[1])
self.assertIsNot(seen[1], seen[2])
self.assertIs(seen[2], seen[3])
self.assertIsNot(get_system_settings(), get_system_settings())

def test_fresh_outside_requests(self):
"""System settings cache is not used outside of a request"""
def test_cleared_on_save(self):
"""Saving the settings drops the cached instance"""
first = get_system_settings()
SystemSettings.objects.filter(pk=True).update(avatars="none")
settings = SystemSettings.objects.get(pk=True)
settings.avatars = "none"
settings.save()
second = get_system_settings()
self.assertIsNot(first, second)
self.assertEqual(second.avatars, "none")
33 changes: 5 additions & 28 deletions authentik/admin/utils.py
Original file line number Diff line number Diff line change
@@ -1,37 +1,14 @@
"""authentik admin utils"""

from collections.abc import Iterator
from contextlib import contextmanager
from contextvars import ContextVar

from authentik.admin.models import SystemSettings

_CTX_SYSTEM_SETTINGS = ContextVar[dict[str, SystemSettings] | None](
"authentik_system_settings", default=None
)
from authentik.lib.config import CONFIG
from authentik.root.cache import in_memory_cache


@in_memory_cache(key=lambda: None, timeout=CONFIG.get_int("cache.timeout_system_settings", 30))
def get_system_settings() -> SystemSettings:
cache = _CTX_SYSTEM_SETTINGS.get()
if cache is None:
return SystemSettings.objects.get(pk=True)
if "settings" not in cache:
cache["settings"] = SystemSettings.objects.get(pk=True)
return cache["settings"]


@contextmanager
def system_settings_cache() -> Iterator[None]:
token = _CTX_SYSTEM_SETTINGS.set({})
try:
yield
finally:
_CTX_SYSTEM_SETTINGS.reset(token)


def clear_system_settings_cache() -> None:
if (cache := _CTX_SYSTEM_SETTINGS.get()) is not None:
cache.clear()
"""Get the system settings. The returned object is shared, don't mutate it."""
return SystemSettings.objects.get(pk=True)


def normalize_base_url(value: str | None) -> str:
Expand Down
4 changes: 4 additions & 0 deletions authentik/api/v3/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ class ConfigSerializer(PassiveSerializer):
cache_timeout = IntegerField(required=True)
cache_timeout_flows = IntegerField(required=True)
cache_timeout_policies = IntegerField(required=True)
cache_timeout_brands = IntegerField(required=True)
cache_timeout_system_settings = IntegerField(required=True)


class ConfigView(APIView):
Expand Down Expand Up @@ -106,6 +108,8 @@ def get_config(request: HttpRequest) -> ConfigSerializer:
"cache_timeout": CONFIG.get_int("cache.timeout"),
"cache_timeout_flows": CONFIG.get_int("cache.timeout_flows"),
"cache_timeout_policies": CONFIG.get_int("cache.timeout_policies"),
"cache_timeout_brands": CONFIG.get_int("cache.timeout_brands"),
"cache_timeout_system_settings": CONFIG.get_int("cache.timeout_system_settings"),
}
)

Expand Down
21 changes: 21 additions & 0 deletions authentik/brands/signals.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
"""authentik brand signals"""

from django.db.models.signals import post_delete, post_save
from django.dispatch import receiver

from authentik.brands.models import Brand
from authentik.brands.utils import get_brand_for_request
from authentik.core.models import Application
from authentik.flows.models import Flow


@receiver(post_save, sender=Brand)
@receiver(post_delete, sender=Brand)
# Cached brands carry their flows and default application (see _BRAND_RELATED_FK_FIELDS)
@receiver(post_save, sender=Flow)
@receiver(post_delete, sender=Flow)
@receiver(post_save, sender=Application)
@receiver(post_delete, sender=Application)
def brand_cache_clear(**_):
"""Drop cached brand lookups when a brand or an object it caches changes"""
get_brand_for_request.cache_clear()
5 changes: 5 additions & 0 deletions authentik/brands/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@
from authentik.admin.models import SystemSettings
from authentik.admin.utils import get_system_settings
from authentik.brands.models import _BRAND_RELATED_FK_FIELDS, SESSION_KEY_BRAND_SAFE_MODE, Brand
from authentik.lib.config import CONFIG
from authentik.lib.tracing import active_tracer
from authentik.root.cache import in_memory_cache

_q_default = Q(default=True)
DEFAULT_BRAND = Brand(domain="fallback")
Expand All @@ -27,6 +29,9 @@ def session_safe_mode(request: HttpRequest) -> bool:
return bool(session.get(SESSION_KEY_BRAND_SAFE_MODE))


@in_memory_cache(
key=lambda request: request.get_host(), timeout=CONFIG.get_int("cache.timeout_brands", 30)
)
def get_brand_for_request(request: HttpRequest) -> Brand:
"""Get brand object for current request"""

Expand Down
2 changes: 0 additions & 2 deletions authentik/core/setup/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@

from authentik.admin.models import SystemSettings
from authentik.admin.utils import (
clear_system_settings_cache,
get_system_settings,
normalize_base_url,
)
Expand Down Expand Up @@ -81,7 +80,6 @@ def get(self, request: HttpRequest, *args, **kwargs):
)
if base_url:
SystemSettings.objects.filter(pk=get_system_settings().pk).update(base_url=base_url)
clear_system_settings_cache()
set_outpost_host(base_url)
# Remember we're setup
Setup.set(True)
Expand Down
3 changes: 3 additions & 0 deletions authentik/events/tests/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from django.db.models import Model
from django.test import TestCase

from authentik.admin.utils import get_system_settings
from authentik.core.models import default_token_key
from authentik.events.models import default_event_duration
from authentik.lib.utils.reflection import get_apps
Expand All @@ -23,8 +24,10 @@ def tester(self: TestModels):
for field in test_model._meta.fields:
if field.default in [default_token_key, default_event_duration]:
allowed += 1
get_system_settings.cache_clear()
with self.assertNumQueries(allowed):
str(test_model())
get_system_settings.cache_clear()
with self.assertNumQueries(allowed):
repr(test_model())

Expand Down
2 changes: 2 additions & 0 deletions authentik/lib/default.yml
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,8 @@ cache:
timeout_policies: 300
# Duration a user's application list, as determined by policies, will be cached.
timeout_application_policies: 86400
timeout_brands: 15
timeout_system_settings: 15

# channel:
# url: ""
Expand Down
67 changes: 67 additions & 0 deletions authentik/root/cache.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
from collections.abc import Callable, Hashable
from concurrent.futures import ThreadPoolExecutor
from functools import wraps
from threading import Lock
from time import monotonic
from typing import Any, Protocol, cast

from django.db import connection


Expand All @@ -7,3 +14,63 @@ def make_key(key: str, key_prefix: str, version: int) -> str:

def reverse_key(key: str) -> str:
return key.split(":", 3)[3]


class CachedFunction[**P, R](Protocol):
def __call__(self, *args: P.args, **kwargs: P.kwargs) -> R: ...
Comment thread
github-code-quality[bot] marked this conversation as resolved.
Fixed
Comment thread
github-code-quality[bot] marked this conversation as resolved.
Fixed
Comment thread
BeryJu marked this conversation as resolved.
Dismissed

def cache_clear(self) -> None: ...
Comment thread
github-code-quality[bot] marked this conversation as resolved.
Fixed
Comment thread
github-code-quality[bot] marked this conversation as resolved.
Fixed
Comment thread
BeryJu marked this conversation as resolved.
Dismissed


class in_memory_cache:
"""Cache a function's result in process memory, keyed by `key(*args, **kwargs)`.

Within `timeout` seconds the cached value is returned as-is. Once stale, the stale
value is still returned while a single background worker refreshes it.
The cache is per-process; call `.cache_clear()` on the wrapped function to invalidate."""

def __init__(self, key: Callable[..., Hashable], timeout: float = 30, maxsize: int = 1024):
self.key = key
self.timeout = timeout
self.maxsize = maxsize
self._values: dict[Hashable, tuple[float, Any]] = {}
self._lock = Lock()
self._executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="in_memory_cache")

def _store(self, key: Hashable, value: Any) -> None:
# drop everything when full instead of LRU; keys can come from
# client input (e.g. Host header), so this only needs to bound memory
if key not in self._values and len(self._values) >= self.maxsize:
self._values.clear()
self._values[key] = (monotonic(), value)

def __call__[**P, R](self, func: Callable[P, R]) -> CachedFunction[P, R]:
def refresh(key: Hashable, args: tuple[Any, ...], kwargs: dict[str, Any]) -> None:
# Another thread is already refreshing; the next stale read will retry
if not self._lock.acquire(blocking=False):
return
try:
# Skip if a refresh finished between the stale read and acquiring the lock
cached = self._values.get(key)
if cached is None or monotonic() - cached[0] > self.timeout:
self._store(key, func(*args, **kwargs))
finally:
self._lock.release()
# The worker thread has its own DB connection, which Django won't close
connection.close()

@wraps(func)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
key = self.key(*args, **kwargs)
cached = self._values.get(key)
if cached is None:
value = func(*args, **kwargs)
self._store(key, value)
return value
stored_at, value = cached
if monotonic() - stored_at > self.timeout and not self._lock.locked():
self._executor.submit(refresh, key, args, kwargs)
return cast(R, value)

wrapper.cache_clear = self._values.clear # type: ignore[attr-defined]
return cast(CachedFunction[P, R], wrapper)
1 change: 0 additions & 1 deletion authentik/root/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -288,7 +288,6 @@
"django_prometheus.middleware.PrometheusBeforeMiddleware",
]
MIDDLEWARE = [
"authentik.admin.middleware.SystemSettingsMiddleware",
"authentik.root.middleware.LoggingMiddleware",
"authentik.root.middleware.ClientIPMiddleware",
"authentik.stages.user_login.middleware.BoundSessionMiddleware",
Expand Down
9 changes: 9 additions & 0 deletions authentik/root/test_plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,15 @@ def pytest_report_header(*_, **__):
]


def pytest_runtest_setup(item: Item):
"""In-memory caches outlive each test's transaction rollback, so start every test empty"""
from authentik.admin.utils import get_system_settings
from authentik.brands.utils import get_brand_for_request

get_brand_for_request.cache_clear()
get_system_settings.cache_clear()


def pytest_collection_modifyitems(config: Config, items: list[Item]) -> None:
current_id = int(environ.get("CI_RUN_ID", "0")) - 1
total_ids = int(environ.get("CI_TOTAL_RUNS", "0"))
Expand Down
Loading
Loading