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
This commit is contained in:
@@ -0,0 +1,96 @@
|
||||
"""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()
|
||||
Reference in New Issue
Block a user