Disable session cache by default and make it opt-in for single-process deployments

This commit is contained in:
2026-04-09 21:52:57 +02:00
parent 4043cdfa3c
commit e1d741956e
4 changed files with 83 additions and 16 deletions

View File

@@ -45,6 +45,21 @@ class Settings(BaseSettings):
ge=1,
description="Number of minutes a session token remains valid after creation.",
)
session_cache_enabled: bool = Field(
default=False,
description=(
"Enable the in-memory session validation cache. "
"Disable it in multi-worker deployments to avoid stale revoked sessions."
),
)
session_cache_ttl_seconds: float = Field(
default=10.0,
ge=0.0,
description=(
"How long (seconds) a cached session validation entry remains fresh. "
"Ignored when session_cache_enabled is false."
),
)
timezone: str = Field(
default="UTC",
description="IANA timezone name used when displaying timestamps in the UI.",

View File

@@ -50,8 +50,6 @@ _COOKIE_NAME = "bangui_session"
#: NOTE: this cache is process-local and is not cluster-safe. In multi-worker
#: or distributed deployments, each process maintains its own cache, so logout
#: invalidation and revocation may be delayed unless a shared cache is used.
_SESSION_CACHE_TTL: float = 10.0
#: ``token → (Session, cache_expiry_monotonic_time)``
_session_cache: dict[str, tuple[Session, float]] = {}
@@ -65,6 +63,11 @@ def clear_session_cache() -> None:
_session_cache.clear()
def _session_cache_enabled(settings: Settings) -> bool:
"""Return whether the in-memory session cache should be used."""
return settings.session_cache_enabled and settings.session_cache_ttl_seconds > 0.0
def invalidate_session_cache(token: str) -> None:
"""Evict *token* from the in-memory session cache.
@@ -213,10 +216,12 @@ async def require_auth(
The token is read from the ``bangui_session`` cookie or the
``Authorization: Bearer`` header.
Validated tokens are cached in memory for :data:`_SESSION_CACHE_TTL`
seconds so that concurrent requests sharing the same token avoid repeated
SQLite round-trips. The cache is bypassed on expiry and explicitly
cleared by :func:`invalidate_session_cache` on logout.
Validated tokens may be cached in memory for a short period so that
concurrent requests sharing the same token avoid repeated SQLite
round-trips. This cache is disabled by default because process-local
invalidation is not safe in multi-worker or clustered deployments.
When enabled, entries are bypassed on expiry and explicitly cleared by
:func:`invalidate_session_cache` on logout.
Args:
request: The incoming FastAPI request.
@@ -244,15 +249,17 @@ async def require_auth(
headers={"WWW-Authenticate": "Bearer"},
)
# Fast path: serve from in-memory cache when the entry is still fresh and
# the session itself has not yet exceeded its own expiry time.
cached = _session_cache.get(token)
if cached is not None:
session, cache_expires_at = cached
if time.monotonic() < cache_expires_at and session.expires_at > utc_now().isoformat():
return session
# Stale cache entry — evict and fall through to DB.
_session_cache.pop(token, None)
cache_enabled = _session_cache_enabled(settings)
if cache_enabled:
# Fast path: serve from in-memory cache when the entry is still fresh and
# the session itself has not yet exceeded its own expiry time.
cached = _session_cache.get(token)
if cached is not None:
session, cache_expires_at = cached
if time.monotonic() < cache_expires_at and session.expires_at > utc_now().isoformat():
return session
# Stale cache entry — evict and fall through to DB.
_session_cache.pop(token, None)
try:
session = await auth_service.validate_session(db, token, settings.session_secret)
@@ -263,7 +270,11 @@ async def require_auth(
headers={"WWW-Authenticate": "Bearer"},
) from exc
_session_cache[token] = (session, time.monotonic() + _SESSION_CACHE_TTL)
if cache_enabled:
_session_cache[token] = (
session,
time.monotonic() + settings.session_cache_ttl_seconds,
)
return session

View File

@@ -165,6 +165,39 @@ class TestRequireAuth:
self, client: AsyncClient
) -> None:
"""Health endpoint is accessible without authentication."""
response = await client.get("/api/health")
assert response.status_code == 200
async def test_session_cache_is_disabled_by_default(
self, client: AsyncClient
) -> None:
"""Session validation does not use the in-memory cache unless enabled."""
from app.repositories import session_repo
await _do_setup(client)
token = await _login(client)
call_count = 0
original_get_session = session_repo.get_session
async def _tracking(db, tok): # type: ignore[no-untyped-def]
nonlocal call_count
call_count += 1
return await original_get_session(db, tok)
with patch.object(session_repo, "get_session", side_effect=_tracking):
resp1 = await client.get(
"/api/dashboard/status",
headers={"Authorization": f"Bearer {token}"},
)
resp2 = await client.get(
"/api/dashboard/status",
headers={"Authorization": f"Bearer {token}"},
)
assert resp1.status_code == 200
assert resp2.status_code == 200
assert call_count == 2
# ---------------------------------------------------------------------------
@@ -184,6 +217,13 @@ class TestRequireAuthSessionCache:
yield
dependencies.clear_session_cache()
@pytest.fixture(autouse=True)
def enable_session_cache(self, client: AsyncClient) -> Generator[None, None, None]:
"""Enable the in-memory auth cache for tests that exercise it."""
client._transport.app.state.settings.session_cache_enabled = True
client._transport.app.state.settings.session_cache_ttl_seconds = 10.0
yield
async def test_second_request_skips_db(self, client: AsyncClient) -> None:
"""Second authenticated request within TTL skips the session DB query.