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:
2026-09-09 21:23:47 +03:00
parent aefd33949c
commit 5a2c9a0688
6 changed files with 725 additions and 0 deletions
+32
View File
@@ -334,3 +334,35 @@ class SecuritySetting(Base):
key = Column(String(50), primary_key=True)
value = Column(String(255), nullable=False)
# ==================== B21/E2: аудит действий ====================
class AuditEvent(Base):
"""Аудит действий (immutable): кто, когда, что. CREATE+READ only."""
__tablename__ = "audit_events"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"))
username = Column(String(100))
event_type = Column(String(40), nullable=False) # user_create|user_update|user_lock|case_create|...
object_type = Column(String(40)) # user|case|operation|...
object_id = Column(String(64))
ip_address = Column(String(64))
user_agent = Column(String(255))
details = Column(JSONB)
created_at = Column(DateTime(timezone=True), server_default=func.now())
__table_args__ = (
sa.Index("idx_audit_events_created", "created_at"),
)
class AuditChange(Base):
"""Field-level изменения: old → new (привязаны к AuditEvent)."""
__tablename__ = "audit_changes"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
event_id = Column(UUID(as_uuid=True), ForeignKey("audit_events.id", ondelete="CASCADE"), nullable=False)
field_name = Column(String(100), nullable=False)
old_value = Column(Text)
new_value = Column(Text)