security(07.3-02): add _rotate_tokens_on_algorithm_change lifespan hook + promote tests

- backend/main.py: add import logging, select; add _rotate_tokens_on_algorithm_change helper above lifespan; call from lifespan with try/except wrap
- backend/tests/test_auth_es256.py: promote ES256-04 and ES256-05 startup rotation tests from xfail to passing; suite reports 6 PASSED + 3 XFAILED
This commit is contained in:
curo1305
2026-06-06 17:02:45 +02:00
parent fd3f611546
commit 8d261b0509
2 changed files with 187 additions and 6 deletions
+57 -1
View File
@@ -1,4 +1,5 @@
import asyncio
import logging
import time
import uuid
from contextlib import asynccontextmanager
@@ -12,7 +13,7 @@ from minio import Minio
from slowapi import _rate_limit_exceeded_handler
from slowapi.errors import RateLimitExceeded
from slowapi.middleware import SlowAPIMiddleware
from sqlalchemy import text
from sqlalchemy import select, text
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.responses import Response as StarletteResponse
from starlette.types import ASGIApp, Receive, Scope, Send
@@ -130,6 +131,49 @@ class CorrelationIDMiddleware:
)
# ── ES256 startup rotation ────────────────────────────────────────────────────
async def _rotate_tokens_on_algorithm_change(session) -> None:
"""Idempotent ES256 migration (Phase 7.3 D-04/D-05).
On every boot, compares the jwt_algorithm marker row in system_settings against
'ES256'. If absent or different, bulk-revokes all active refresh tokens via raw
SQL UPDATE and upserts the marker (with is_active=False so the AI provider loader
never returns this row). No-op on second boot.
"""
from db.models import SystemSettings # local import to avoid circular deps
stmt = select(SystemSettings).where(SystemSettings.provider_id == "jwt_algorithm")
result = await session.execute(stmt)
row = result.scalar_one_or_none()
if row is not None and row.model_name == "ES256":
return # Already migrated — idempotent no-op
# Bulk-revoke all active refresh tokens (single raw SQL — no Python iteration)
await session.execute(
text("UPDATE refresh_tokens SET revoked = true WHERE revoked = false")
)
logging.getLogger(__name__).info(
"ES256 startup rotation: bulk-revoked all active refresh tokens"
)
if row is None:
session.add(SystemSettings(
id=uuid.uuid4(),
provider_id="jwt_algorithm",
model_name="ES256",
context_chars=0,
is_active=False,
api_key_enc=None,
base_url=None,
))
else:
row.model_name = "ES256"
await session.commit()
# ── Lifespan ──────────────────────────────────────────────────────────────────
@asynccontextmanager
@@ -179,6 +223,18 @@ async def lifespan(app: FastAPI):
"AI provider seed skipped (table may not exist yet): %s", _seed_exc
)
# ES256 startup rotation (Phase 7.3 — D-04):
# If jwt_algorithm in system_settings differs from "ES256" (or is absent),
# bulk-revoke all refresh tokens and record the new algorithm.
# Wrapped in try/except so a missing table before migrations doesn't crash.
try:
async with AsyncSessionLocal() as session:
await _rotate_tokens_on_algorithm_change(session)
except Exception as _es256_exc:
logging.getLogger(__name__).warning(
"ES256 rotation check skipped (table may not exist yet): %s", _es256_exc
)
yield
# Shutdown: close pooled connections and Redis