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
+51
View File
@@ -0,0 +1,51 @@
"""E2 B21: аудит (audit_events + audit_changes, immutable, 90 дней).
Revision ID: 008_e2_audit
Revises: 007_e1_rbac
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql as pg
revision = '008_e2_audit'
down_revision = '007_e1_rbac'
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
'audit_events',
sa.Column('id', pg.UUID(as_uuid=True), primary_key=True,
server_default=sa.text('gen_random_uuid()')),
sa.Column('user_id', pg.UUID(as_uuid=True), sa.ForeignKey('users.id', ondelete='SET NULL')),
sa.Column('username', sa.String(100)),
sa.Column('event_type', sa.String(40), nullable=False),
sa.Column('object_type', sa.String(40)),
sa.Column('object_id', sa.String(64)),
sa.Column('ip_address', sa.String(64)),
sa.Column('user_agent', sa.String(255)),
sa.Column('details', sa.JSON().with_variant(pg.JSONB(), 'postgresql')),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('NOW()')),
)
op.create_index('idx_audit_events_created', 'audit_events', ['created_at'])
op.create_table(
'audit_changes',
sa.Column('id', pg.UUID(as_uuid=True), primary_key=True,
server_default=sa.text('gen_random_uuid()')),
sa.Column('event_id', pg.UUID(as_uuid=True),
sa.ForeignKey('audit_events.id', ondelete='CASCADE'), nullable=False),
sa.Column('field_name', sa.String(100), nullable=False),
sa.Column('old_value', sa.Text()),
sa.Column('new_value', sa.Text()),
)
op.create_index('idx_audit_changes_event', 'audit_changes', ['event_id'])
def downgrade() -> None:
op.drop_index('idx_audit_changes_event', table_name='audit_changes')
op.drop_table('audit_changes')
op.drop_index('idx_audit_events_created', table_name='audit_events')
op.drop_table('audit_events')