Files
vector/backend/purge_logs.py
T
Sadmin 2222b7ad5b B23: политика паролей исполняется + ретенция журналов по-настоящему
- backend/password_policy.py: validate_new_password (длина, сложность
  буквы+цифры, сверка с текущим и историей), record_password_change
  (хранит ПРЕДЫДУШИЙ хэш, обрезка до password_history_count),
  password_expired + enforce_expiry (пометка must_change_password)
- users.password_changed_at + таблица password_history (010_b23_policy)
- change-password и админ-сброс теперь под полной политикой (сложность
  и переиспользование вместо голой длины); get_current_user проверяет
  срок действия каждого пароля при запросе
- backend/purge_logs.py: реальный DELETE просроченных audit_events
  (+changes) и auth_events; POST /admin/users/maintenance/purge-logs
  (manage_security) + CLI python -m backend.purge_logs для cron
- тесты: +19 (258 passed) — валидация/история/срок, purge/идемпотентность/403
2026-09-25 09:56:26 +03:00

96 lines
4.0 KiB
Python

"""B23: ретенция журналов — реальное удаление, а не фильтр при чтении.
До сих пор «ретенция 90 дней» из security_settings реализовывалась как
фильтр в GET /admin/users/audit — записи копились в БД вечно. Этот модуль
удаляет просроченное по-настоящему (DELETE), охватывая:
audit_events (+ audit_changes каскадно) — по audit_retention_days
auth_events — по audit_retention_days
Запуск: POST /api/v1/admin/maintenance/purge-logs (право manage_security)
или вручную:
docker exec vector-backend python -m backend.purge_logs
(режим разового запуска — для cron внутри контейнера)
"""
from __future__ import annotations
import os
from datetime import datetime, timedelta, timezone
from sqlalchemy import text
from sqlalchemy.orm import Session
from backend.logging_setup import get_logger
from backend.models import AuthEvent, SecuritySetting
log = get_logger('retention')
def _retention_days(db: Session, default: int = 90) -> int:
row = db.query(SecuritySetting).filter(
SecuritySetting.key == 'audit_retention_days').first()
try:
return int(row.value) if row else default
except (TypeError, ValueError):
return default
def purge_logs(db: Session, retention_days: int | None = None) -> dict[str, int]:
"""Удалить записи журналов старше retention_days. Возвращает счётчики.
audit_changes удаляются каскадом по FK (ondelete=CASCADE); для SQLite-тестов
(без каскада на уровне БД) удаляем явно.
"""
if retention_days is None:
retention_days = _retention_days(db)
cutoff = datetime.now(timezone.utc) - timedelta(days=max(0, retention_days))
# SQLite отдаёт naive datetimes (DateTime(timezone=True) не respected) —
# сравнение aware-cutoff с naive-колонкой падает TypeError. Убираем tzinfo:
cutoff = cutoff.replace(tzinfo=None)
audit_deleted = 0
try:
from backend.models import AuditChange, AuditEvent
change_ids = (db.query(AuditChange.id)
.join(AuditEvent, AuditChange.event_id == AuditEvent.id)
.filter(AuditEvent.created_at < cutoff).all())
change_ids = [row[0] for row in change_ids]
if change_ids:
(db.query(AuditChange)
.filter(AuditChange.id.in_(change_ids))
.delete(synchronize_session=False)) # changes считаются вместе с событием
audit_deleted += (db.query(AuditEvent)
.filter(AuditEvent.created_at < cutoff)
.delete(synchronize_session=False))
except Exception as e:
db.rollback()
log.error('purge audit_events не удался: %s', e)
raise
auth_deleted = 0
try:
auth_deleted += (db.query(AuthEvent)
.filter(AuthEvent.created_at < cutoff)
.delete(synchronize_session=False))
except Exception as e:
log.error('purge auth_events не удался: %s', e)
raise
db.commit()
if audit_deleted or auth_deleted:
log.info('retention purge: %d аудит-записей, %d auth-записей удалено (cutoff=%s, %d дн.)',
audit_deleted, auth_deleted, cutoff.date(), retention_days)
return {'audit_events': audit_deleted, 'auth_events': auth_deleted}
def _cli() -> None:
"""Разовый прогон: python -m backend.purge_logs (для cron)."""
from backend.database import SessionLocal
with SessionLocal() as db:
result = purge_logs(db)
print(f'purge: {result}')
raise SystemExit(0)
if __name__ == '__main__':
_cli()