Ревью кодовой базы (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>
80 lines
3.8 KiB
Python
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)
|