diff --git a/decnet/web/dependencies.py b/decnet/web/dependencies.py index 20dd2d9..2c7f12c 100644 --- a/decnet/web/dependencies.py +++ b/decnet/web/dependencies.py @@ -1,3 +1,5 @@ +import asyncio +import time from typing import Any, Optional import jwt @@ -23,6 +25,49 @@ repo = get_repo() oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login") +# Per-request user lookup was the hidden tax behind every authed endpoint — +# SELECT users WHERE uuid=? ran once per call, serializing through aiosqlite. +# 10s TTL is well below JWT expiry and we invalidate on all user writes. +_USER_TTL = 10.0 +_user_cache: dict[str, tuple[Optional[dict[str, Any]], float]] = {} +_user_cache_lock: Optional[asyncio.Lock] = None + + +def _reset_user_cache() -> None: + global _user_cache, _user_cache_lock + _user_cache = {} + _user_cache_lock = None + + +def invalidate_user_cache(user_uuid: Optional[str] = None) -> None: + """Drop a single user (or all users) from the auth cache. + + Callers: password change, role change, user create/delete. + """ + if user_uuid is None: + _user_cache.clear() + else: + _user_cache.pop(user_uuid, None) + + +async def _get_user_cached(user_uuid: str) -> Optional[dict[str, Any]]: + global _user_cache_lock + entry = _user_cache.get(user_uuid) + now = time.monotonic() + if entry is not None and now - entry[1] < _USER_TTL: + return entry[0] + if _user_cache_lock is None: + _user_cache_lock = asyncio.Lock() + async with _user_cache_lock: + entry = _user_cache.get(user_uuid) + now = time.monotonic() + if entry is not None and now - entry[1] < _USER_TTL: + return entry[0] + user = await repo.get_user_by_uuid(user_uuid) + _user_cache[user_uuid] = (user, time.monotonic()) + return user + + async def get_stream_user(request: Request, token: Optional[str] = None) -> str: """Auth dependency for SSE endpoints — accepts Bearer header OR ?token= query param. EventSource does not support custom headers, so the query-string fallback is intentional here only. @@ -82,7 +127,7 @@ async def _decode_token(request: Request) -> str: async def get_current_user(request: Request) -> str: """Auth dependency — enforces must_change_password.""" _user_uuid = await _decode_token(request) - _user = await repo.get_user_by_uuid(_user_uuid) + _user = await _get_user_cached(_user_uuid) if _user and _user.get("must_change_password"): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, @@ -112,7 +157,7 @@ def require_role(*allowed_roles: str): """ async def _check(request: Request) -> dict: user_uuid = await _decode_token(request) - user = await repo.get_user_by_uuid(user_uuid) + user = await _get_user_cached(user_uuid) if not user: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, @@ -137,7 +182,7 @@ def require_stream_role(*allowed_roles: str): """Like ``require_role`` but for SSE endpoints that accept a query-param token.""" async def _check(request: Request, token: Optional[str] = None) -> dict: user_uuid = await get_stream_user(request, token) - user = await repo.get_user_by_uuid(user_uuid) + user = await _get_user_cached(user_uuid) if not user or user["role"] not in allowed_roles: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, diff --git a/decnet/web/router/auth/api_change_pass.py b/decnet/web/router/auth/api_change_pass.py index efca5bf..592b11e 100644 --- a/decnet/web/router/auth/api_change_pass.py +++ b/decnet/web/router/auth/api_change_pass.py @@ -4,7 +4,7 @@ from fastapi import APIRouter, Depends, HTTPException, status from decnet.telemetry import traced as _traced from decnet.web.auth import ahash_password, averify_password -from decnet.web.dependencies import get_current_user_unchecked, repo +from decnet.web.dependencies import get_current_user_unchecked, invalidate_user_cache, repo from decnet.web.db.models import ChangePasswordRequest router = APIRouter() @@ -30,4 +30,5 @@ async def change_password(request: ChangePasswordRequest, current_user: str = De _new_hash: str = await ahash_password(request.new_password) await repo.update_user_password(current_user, _new_hash, must_change_password=False) + invalidate_user_cache(current_user) return {"message": "Password updated successfully"} diff --git a/decnet/web/router/config/api_get_config.py b/decnet/web/router/config/api_get_config.py index de12516..d21f474 100644 --- a/decnet/web/router/config/api_get_config.py +++ b/decnet/web/router/config/api_get_config.py @@ -20,13 +20,45 @@ _STATE_TTL = 5.0 _state_cache: dict[str, tuple[Optional[dict[str, Any]], float]] = {} _state_locks: dict[str, asyncio.Lock] = {} +# Admin branch fetched repo.list_users() on every /config call — cache 5s, +# invalidate on user create/update/delete so the admin UI stays consistent. +_USERS_TTL = 5.0 +_users_cache: tuple[Optional[list[dict[str, Any]]], float] = (None, 0.0) +_users_lock: Optional[asyncio.Lock] = None + def _reset_state_cache() -> None: """Reset cached config state — used by tests.""" + global _users_cache, _users_lock _state_cache.clear() # Drop any locks bound to the previous event loop — reusing one from # a dead loop deadlocks the next test. _state_locks.clear() + _users_cache = (None, 0.0) + _users_lock = None + + +def invalidate_list_users_cache() -> None: + global _users_cache + _users_cache = (None, 0.0) + + +async def _get_list_users_cached() -> list[dict[str, Any]]: + global _users_cache, _users_lock + value, ts = _users_cache + now = time.monotonic() + if value is not None and now - ts < _USERS_TTL: + return value + if _users_lock is None: + _users_lock = asyncio.Lock() + async with _users_lock: + value, ts = _users_cache + now = time.monotonic() + if value is not None and now - ts < _USERS_TTL: + return value + value = await repo.list_users() + _users_cache = (value, time.monotonic()) + return value async def _get_state_cached(name: str) -> Optional[dict[str, Any]]: @@ -76,7 +108,7 @@ async def api_get_config(user: dict = Depends(require_viewer)) -> dict: } if user["role"] == "admin": - all_users = await repo.list_users() + all_users = await _get_list_users_cached() base["users"] = [ UserResponse( uuid=u["uuid"], diff --git a/decnet/web/router/config/api_manage_users.py b/decnet/web/router/config/api_manage_users.py index 976c810..70e0fe9 100644 --- a/decnet/web/router/config/api_manage_users.py +++ b/decnet/web/router/config/api_manage_users.py @@ -4,7 +4,8 @@ from fastapi import APIRouter, Depends, HTTPException from decnet.telemetry import traced as _traced from decnet.web.auth import ahash_password -from decnet.web.dependencies import require_admin, repo +from decnet.web.dependencies import require_admin, invalidate_user_cache, repo +from decnet.web.router.config.api_get_config import invalidate_list_users_cache from decnet.web.db.models import ( CreateUserRequest, UpdateUserRoleRequest, @@ -43,6 +44,7 @@ async def api_create_user( "role": req.role, "must_change_password": True, # nosec B105 — not a password }) + invalidate_list_users_cache() return UserResponse( uuid=user_uuid, username=req.username, @@ -71,6 +73,8 @@ async def api_delete_user( deleted = await repo.delete_user(user_uuid) if not deleted: raise HTTPException(status_code=404, detail="User not found") + invalidate_user_cache(user_uuid) + invalidate_list_users_cache() return {"message": "User deleted"} @@ -99,6 +103,8 @@ async def api_update_user_role( raise HTTPException(status_code=404, detail="User not found") await repo.update_user_role(user_uuid, req.role) + invalidate_user_cache(user_uuid) + invalidate_list_users_cache() return {"message": "User role updated"} @@ -128,4 +134,6 @@ async def api_reset_user_password( await ahash_password(req.new_password), must_change_password=True, ) + invalidate_user_cache(user_uuid) + invalidate_list_users_cache() return {"message": "Password reset successfully"} diff --git a/tests/api/conftest.py b/tests/api/conftest.py index aa1ddbf..d7dc8b3 100644 --- a/tests/api/conftest.py +++ b/tests/api/conftest.py @@ -62,8 +62,10 @@ async def setup_db(monkeypatch) -> AsyncGenerator[None, None]: from decnet.web.router.bounty import api_get_bounties as _b from decnet.web.router.logs import api_get_histogram as _lh from decnet.web.router.fleet import api_get_deckies as _d + from decnet.web import dependencies as _deps _h._reset_db_cache() _c._reset_state_cache() + _deps._reset_user_cache() _s._reset_stats_cache() _l._reset_total_cache() _a._reset_total_cache()