E2 (B21): аудит + админ-панель пользователей + гео-скоуп
- Модели audit_events (immutable) + audit_changes (field-level old→new);
Alembic 008_e2_audit.
- backend/audit.py: audit_log() (событие + изменения), audit_retention_days
(90 дней из security_settings), visible_unit_ids (рекурсивный CTE:
своё подразделение + подчинённые; РЦУ РЧС/legacy → None = без фильтра),
can_access_unit, _uuid_hex.
- backend/routers/admin_users.py (manage_users/view_audit):
GET /admin/users (фильтры status/unit/q, скоуп по подразделению),
POST (создание с must_change_password, роль, скоуп-проверка),
PATCH /{id} (роль/юнит/статус/сброс пароля, field-level аудит),
GET /units (справочник подразделений), GET /audit (ретеншн-фильтр).
coordinator управляет только своим юнитом+подчинёнными и не трогает РЦУ.
- Грабли SQLite-тестов: UUID колонки хранят hex без дефисов — сравнения
только в hex-пространстве (_uuid_hex); StaticPool обязателен.
- Тесты E2 (8): создание/блокировка/сброс пароля/скоуп координатора/
cross-oblast 403/без права 403/аудит user_create.
210 passed, 5 skipped.
This commit is contained in:
@@ -0,0 +1,99 @@
|
||||
"""B21/E2: аудит-хелперы и скоуп подразделений.
|
||||
|
||||
audit_log() — запись действия в audit_events (+audit_changes для field-level).
|
||||
visible_unit_ids() — гео-скоуп: [unit_id] своё подразделение + потомки;
|
||||
для РЦУ РЧС-admin — None (без фильтра).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from backend.models import AuditChange, AuditEvent, SecuritySetting, User
|
||||
|
||||
|
||||
def _client_meta(request) -> tuple[str | None, str | None]:
|
||||
if request is None:
|
||||
return None, None
|
||||
ip = request.client.host if request.client else None
|
||||
return ip, (request.headers.get('user-agent') or '')[:255]
|
||||
|
||||
|
||||
def audit_log(
|
||||
db: Session,
|
||||
actor: User | None,
|
||||
event_type: str,
|
||||
object_type: str | None = None,
|
||||
object_id: str | None = None,
|
||||
changes: dict[str, tuple] | None = None,
|
||||
request=None,
|
||||
details: dict | None = None,
|
||||
) -> None:
|
||||
"""Записать действие в аудит. changes = {field: (old, new)}."""
|
||||
ip, ua = _client_meta(request)
|
||||
event = AuditEvent(
|
||||
user_id=getattr(actor, 'id', None),
|
||||
username=getattr(actor, 'username', None),
|
||||
event_type=event_type,
|
||||
object_type=object_type,
|
||||
object_id=str(object_id) if object_id else None,
|
||||
ip_address=ip,
|
||||
user_agent=ua,
|
||||
details=details or {},
|
||||
)
|
||||
db.add(event)
|
||||
db.flush()
|
||||
for field, (old, new) in (changes or {}).items():
|
||||
db.add(AuditChange(
|
||||
event_id=event.id,
|
||||
field_name=field,
|
||||
old_value=None if old is None else str(old),
|
||||
new_value=None if new is None else str(new),
|
||||
))
|
||||
db.commit()
|
||||
|
||||
|
||||
def audit_retention_days(db: Session) -> int:
|
||||
row = db.query(SecuritySetting).filter(SecuritySetting.key == 'audit_retention_days').first()
|
||||
return int(row.value) if row else 90
|
||||
|
||||
|
||||
def _uuid_hex(value) -> str:
|
||||
"""UUID → hex-строка без дефисов (UUID-колонка хранит hex в sqlite-тестах)."""
|
||||
if hasattr(value, 'hex'):
|
||||
return value.hex
|
||||
return uuid.UUID(str(value)).hex
|
||||
|
||||
|
||||
def visible_unit_ids(db: Session, user: User) -> list[str] | None:
|
||||
"""Гео-скоуп: [unit_id] свои + подчинённые подразделения.
|
||||
None = без фильтра (РЦУ РЧС/admin)."""
|
||||
import uuid
|
||||
unit_id = getattr(user, 'unit_id', None)
|
||||
if not unit_id:
|
||||
return None # РЦУ РЧС или legacy-пользователь без юнита — видит всё
|
||||
rows = db.execute(
|
||||
text("""
|
||||
WITH RECURSIVE tree AS (
|
||||
SELECT id FROM mchs_units WHERE id = :uid
|
||||
UNION ALL
|
||||
SELECT u.id FROM mchs_units u JOIN tree t ON u.parent_id = t.id
|
||||
)
|
||||
SELECT id FROM tree
|
||||
"""),
|
||||
{'uid': _uuid_hex(unit_id)},
|
||||
).fetchall()
|
||||
return [str(r[0]) for r in rows]
|
||||
|
||||
|
||||
def can_access_unit(db: Session, user: User, target_unit_id: str | None) -> bool:
|
||||
"""Может ли пользователь действовать над объектом принадлежащим target_unit_id."""
|
||||
scope = visible_unit_ids(db, user)
|
||||
if scope is None:
|
||||
return True
|
||||
if not target_unit_id:
|
||||
return False
|
||||
return _uuid_hex(target_unit_id) in scope
|
||||
Reference in New Issue
Block a user