Закрыли часть проблем с безопасностью + мелкие починки

This commit is contained in:
mi
2026-07-29 18:21:08 +03:00
parent bda3ff39d7
commit 049c45db5c
13 changed files with 716 additions and 339 deletions
+4
View File
@@ -31,6 +31,10 @@ class JWKSValidator:
self._loaded_at = 0.0
self._lock = asyncio.Lock()
@property
def has_keys(self) -> bool:
return bool(self._keys)
async def refresh(self) -> None:
async with self._lock:
discovery_url = (
+21 -4
View File
@@ -113,6 +113,21 @@ async def refresh_settings_cache(app: FastAPI) -> None:
await asyncio.sleep(30)
async def refresh_jwks_cache(app: FastAPI) -> None:
retry_delay = 5
refresh_delay = max(30, app.state.settings.jwks_cache_ttl_seconds)
delay = refresh_delay if app.state.jwks.has_keys else retry_delay
while True:
await asyncio.sleep(delay)
try:
await app.state.jwks.refresh()
except Exception:
log.warning("jwks.refresh_failed")
delay = retry_delay
else:
delay = refresh_delay
@asynccontextmanager
async def lifespan(app: FastAPI):
settings = get_settings()
@@ -142,12 +157,14 @@ async def lifespan(app: FastAPI):
await app.state.jwks.refresh()
except Exception:
structlog.get_logger().warning("jwks.warmup_failed")
jwks_task = asyncio.create_task(refresh_jwks_cache(app))
try:
yield
finally:
settings_task.cancel()
with suppress(asyncio.CancelledError):
await settings_task
for task in (settings_task, jwks_task):
task.cancel()
with suppress(asyncio.CancelledError):
await task
await app.state.http.aclose()
await app.state.redis.aclose()
await app.state.redis_rt.aclose()
@@ -462,7 +479,7 @@ async def ready(request: Request, db: Session):
components[name] = "ok"
except Exception:
components[name] = "failed"
components["jwks"] = "ok" if request.app.state.jwks._keys else "failed"
components["jwks"] = "ok" if request.app.state.jwks.has_keys else "failed"
try:
safety_response = await request.app.state.http.get(
f"{str(request.app.state.settings.message_safety_url).rstrip('/')}/health/ready",
@@ -1,14 +1,22 @@
import asyncio
import base64
import json
from pathlib import Path
from types import SimpleNamespace
import pytest
import yaml
from alembic.config import Config
from alembic.script import ScriptDirectory
from pydantic import SecretStr
from app.main import EXPECTED_API_DB_REVISION, app, otp_settings, websocket_token
from app.main import (
EXPECTED_API_DB_REVISION,
app,
otp_settings,
refresh_jwks_cache,
websocket_token,
)
from app.services import SettingsSnapshot
EXPECTED_PATHS = {
@@ -65,6 +73,39 @@ def test_readiness_expected_revision_matches_alembic_head() -> None:
assert EXPECTED_API_DB_REVISION == scripts.get_current_head()
@pytest.mark.asyncio
async def test_jwks_refresh_loop_recovers_after_startup_race(monkeypatch) -> None:
class FakeJWKS:
has_keys = False
refresh_calls = 0
async def refresh(self) -> None:
self.refresh_calls += 1
self.has_keys = True
jwks = FakeJWKS()
test_app = SimpleNamespace(
state=SimpleNamespace(
jwks=jwks,
settings=SimpleNamespace(jwks_cache_ttl_seconds=300),
)
)
delays: list[int] = []
async def fake_sleep(delay: int) -> None:
delays.append(delay)
if len(delays) > 1:
raise asyncio.CancelledError
monkeypatch.setattr("app.main.asyncio.sleep", fake_sleep)
with pytest.raises(asyncio.CancelledError):
await refresh_jwks_cache(test_app)
assert jwks.refresh_calls == 1
assert delays == [5, 300]
def test_websocket_route_is_registered() -> None:
assert any(getattr(route, "path", None) == "/api/v1/realtime" for route in app.routes)