Files
ipam_control/app/rotation.py
T
ayurishchevandClaude Opus 5.5 13e17fbb47 Задачи 011-024: доработки по ревью кодовой базы и исправление находок
Ревью кодовой базы (docs/reviews/2026-09-26-codebase-review.md) и планы по каждой находке:
011 IP уникален в VRF и хранится в самом узком префиксе (addresses.vrf_id, составной FK
    с каскадом при переносе VRF, миграция 0007 с остановкой на дублях).
012 Ограничение попыток входа (login_attempts, 429 + Retry-After), выравнивание времени
    ответа, журнал без вытеснения анонимными событиями (миграция 0006).
013 Границы пагинации: отрицательные/чрезмерные limit/offset дают 422 вместо 500.
014 Экран адресов: страница свободных адресов арифметикой, пагинация в SQL.
015 Запрет адреса сети/broadcast, загрузка не выше 100 %.
016 Роль по умолчанию — viewer.
017 Проверка JWT_SECRET/ADMIN_PASSWORD при старте.
018 null в PATCH очищает текстовые поля; нейтральный текст конфликта БД.
019 Пакетная загрузка в списках вместо N+1.
020 Автоназначение адреса вне вложенных префиксов, с блокировкой префикса.
021 Advisory-lock при снятии прав администратора, уникальный lower(username) (миграция 0008).
022 Контейнер не от root, healthcheck, блокировка миграций, requirements.lock.
023 Экранирование LIKE, журнал отказов очистки, заголовки безопасности, учёт force-удаления,
    отзыв токенов при смене пароля (claim pv, миграция 0005).
024 Исправление находок ревью 011-023 (docs/reviews/2026-09-26-changes-011-023-review.md):
    сериализация попыток входа, запрет переноса адресов в адрес сети/broadcast, журнал входов,
    запрет смены своего пароля через PATCH, валидация PATCH устройства, обновлён тест токенов.

Тесты: 14 passed. Документация: README.md, docs/changes/011-024, docs/reviews.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 21:33:50 +03:00

80 lines
3.8 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, LoginAttempt
from app.services import SYSTEM, audit
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)))
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)