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,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')
|
||||
Reference in New Issue
Block a user