Files
ayurishchevandClaude Opus 5.5 03d727e496 Задачи 025-030: ёмкость префиксов, политика входа, дерево префиксов
Повторный анализ кодовой базы (docs/reviews/2026-09-26-codebase-review-2.md) и доработки:
025 Ёмкость префикса — размер его подсети (а не сумма листьев); «Обзор» считает ёмкость
    по корневым активным IPv4-префиксам и адреса внутри них.
026 Политика блокировки входа: 5 неудач на логин+IP, 20 на IP, 50 на логин со всех IP,
    кроме известных IP (known_logins, миграция 0009) — владельца нельзя заблокировать анонимно.
027 Сериализация попыток входа по IP (advisory-lock после блокировки логина).
028 UI «Префиксы»: загрузка всех страниц (до 20 000), счётчики по total, предупреждение об усечении.
029 Advisory-lock по VRF для операций, меняющих дерево префиксов и раскладку адресов.
030 Исправление замечаний ревью 025-029: _lock_prefix (VRF блокируется до чтения префикса,
    409 при одновременном переносе), константы политики входа перенесены в app/services.py.

Тесты: 14 passed (проверка ёмкости родителя приведена к семантике 025); сквозные сценарии
и гонки — docs/reviews/2026-09-26-changes-025-029-review.md, 2026-09-27-changes-030-review.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 08:28:50 +03:00

81 lines
4.1 KiB
Python

"""Настройки и ротация журнала: удаление записей старше срока и самых старых сверх лимита."""
import asyncio
import logging
from datetime import datetime, timedelta, timezone
from sqlalchemy import delete, func, select
from sqlalchemy.orm import Session
from app.db import SessionLocal
from app.models import AppSetting, AuditLog, KnownLogin, LoginAttempt
from app.services import KNOWN_IP_DAYS, SYSTEM, audit # KNOWN_IP_DAYS — из app.services, не из API-слоя (изменение 030, ревью 025-029 находка №2)
log = logging.getLogger("ipam.rotation")
DEFAULTS = {"retention_days": 90, "max_entries": 100_000}
LOCK_KEY = 703001 # advisory lock: одна ротация за раз, даже при нескольких репликах
INTERVAL_SECONDS = 3600
def get_settings(db: Session) -> dict:
row = db.get(AppSetting, "journal")
return {**DEFAULTS, **(row.value if row else {})}
def save_settings(db: Session, values: dict) -> None:
row = db.get(AppSetting, "journal")
if row is None:
db.add(AppSetting(key="journal", value=values))
else:
row.value = values
def rotate(db: Session) -> dict | None:
"""Возвращает {'by_age', 'by_count'} или None, если ротацию уже выполняет другой процесс."""
if not db.scalar(select(func.pg_try_advisory_xact_lock(LOCK_KEY))):
db.rollback()
return None
cfg = get_settings(db)
by_age = by_count = 0
if cfg["retention_days"] > 0:
cutoff = datetime.now(timezone.utc) - timedelta(days=cfg["retention_days"])
by_age = db.execute(delete(AuditLog).where(AuditLog.ts < cutoff)).rowcount
if cfg["max_entries"] > 0:
total = db.scalar(select(func.count()).select_from(AuditLog)) or 0
if total > cfg["max_entries"]:
# удаляем с запасом в одну запись — под сводную запись о ротации, чтобы итог не превышал лимит
n = total - cfg["max_entries"] + 1
# сначала неудачные и заблокированные входы (их может нагенерировать кто угодно), затем самые старые записи остальных типов (изменение 024, находка №4)
noisy = db.execute(delete(AuditLog).where(AuditLog.id.in_(
select(AuditLog.id).where(AuditLog.entity_type == "session", AuditLog.action.in_(("failed", "locked"))).order_by(AuditLog.id).limit(n)))).rowcount
rest = n - noisy
by_count = noisy + (db.execute(delete(AuditLog).where(AuditLog.id.in_(select(AuditLog.id).order_by(AuditLog.id).limit(rest)))).rowcount if rest > 0 else 0)
db.execute(delete(LoginAttempt).where(LoginAttempt.ts < datetime.now(timezone.utc) - timedelta(days=1)))
db.execute(delete(KnownLogin).where(KnownLogin.last_seen < datetime.now(timezone.utc) - timedelta(days=KNOWN_IP_DAYS))) # изменение 026
if by_age or by_count:
parts = []
if by_age:
parts.append(f"старше {cfg['retention_days']} дн.: {by_age}")
if by_count:
parts.append(f"сверх лимита {cfg['max_entries']}: {by_count}")
audit(db, SYSTEM, "journal", None, "rotated", "rotation", {"by_age": by_age, "by_count": by_count},
message="Ротация журнала: удалено " + "; ".join(parts))
db.commit()
return {"by_age": by_age, "by_count": by_count}
def run_rotation() -> None:
try:
with SessionLocal() as db:
result = rotate(db)
if result and (result["by_age"] or result["by_count"]):
log.info("journal rotated: %s", result)
except Exception: # фоновая задача не должна падать
log.exception("journal rotation failed")
async def rotation_loop() -> None:
await asyncio.sleep(30)
while True:
await asyncio.to_thread(run_rotation)
await asyncio.sleep(INTERVAL_SECONDS)