"""E1 B21: RBAC-ядро — подразделения МЧС, роли/права, сессии, auth-журнал. Revision ID: 007_e1_rbac Revises: 006_b15_layers """ from __future__ import annotations import sqlalchemy as sa from alembic import op from sqlalchemy.dialects import postgresql as pg revision = '007_e1_rbac' down_revision = '006_b15_layers' branch_labels = None depends_on = None def upgrade() -> None: # ---------- Подразделения ---------- op.create_table( 'mchs_units', sa.Column('id', pg.UUID(as_uuid=True), primary_key=True, server_default=sa.text('gen_random_uuid()')), sa.Column('name', sa.String(255), nullable=False, unique=True), sa.Column('region', sa.String(100)), sa.Column('kind', sa.String(20), nullable=False), sa.Column('parent_id', pg.UUID(as_uuid=True), sa.ForeignKey('mchs_units.id', ondelete='SET NULL')), sa.Column('created_at', sa.DateTime(), server_default=sa.text('NOW()')), sa.CheckConstraint("kind IN ('rcu','oblast','gor_rayon')", name='ck_mchs_units_kind'), ) # users: RBAC-колонки op.add_column('users', sa.Column('unit_id', pg.UUID(as_uuid=True), sa.ForeignKey('mchs_units.id', ondelete='SET NULL'))) op.add_column('users', sa.Column('position', sa.String(255))) op.add_column('users', sa.Column('status', sa.String(20), server_default='active')) op.add_column('users', sa.Column('failed_login_count', sa.Integer(), server_default='0')) op.add_column('users', sa.Column('locked_until', sa.DateTime(timezone=True))) op.add_column('users', sa.Column('must_change_password', sa.Boolean(), server_default=sa.text('false'))) op.add_column('users', sa.Column('created_by', pg.UUID(as_uuid=True), sa.ForeignKey('users.id', ondelete='SET NULL'))) op.add_column('users', sa.Column('updated_at', sa.DateTime(), server_default=sa.text('NOW()'))) # ---------- Роли / права ---------- op.create_table( 'roles', sa.Column('id', pg.UUID(as_uuid=True), primary_key=True, server_default=sa.text('gen_random_uuid()')), sa.Column('name', sa.String(50), nullable=False, unique=True), sa.Column('description', sa.String(255)), sa.Column('is_system', sa.Boolean(), server_default=sa.text('false')), sa.Column('created_at', sa.DateTime(), server_default=sa.text('NOW()')), ) op.create_table( 'permissions', sa.Column('id', pg.UUID(as_uuid=True), primary_key=True, server_default=sa.text('gen_random_uuid()')), sa.Column('code', sa.String(50), nullable=False, unique=True), sa.Column('description', sa.String(255)), ) op.create_table( 'role_permissions', sa.Column('role_id', pg.UUID(as_uuid=True), sa.ForeignKey('roles.id', ondelete='CASCADE'), primary_key=True), sa.Column('permission_id', pg.UUID(as_uuid=True), sa.ForeignKey('permissions.id', ondelete='CASCADE'), primary_key=True), ) op.create_table( 'user_roles', sa.Column('user_id', pg.UUID(as_uuid=True), sa.ForeignKey('users.id', ondelete='CASCADE'), primary_key=True), sa.Column('role_id', pg.UUID(as_uuid=True), sa.ForeignKey('roles.id', ondelete='CASCADE'), primary_key=True), sa.Column('assigned_by', pg.UUID(as_uuid=True), sa.ForeignKey('users.id', ondelete='SET NULL')), sa.Column('assigned_at', sa.DateTime(), server_default=sa.text('NOW()')), ) # ---------- Сессии / журнал / настройки ---------- op.create_table( 'sessions', 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='CASCADE'), nullable=False), sa.Column('token_hash', sa.String(64), nullable=False, unique=True), sa.Column('ip_address', sa.String(45)), sa.Column('user_agent', sa.String(255)), sa.Column('created_at', sa.DateTime(), server_default=sa.text('NOW()')), sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False), sa.Column('last_seen_at', sa.DateTime(timezone=True)), sa.Column('is_active', sa.Boolean(), server_default=sa.text('true')), ) op.create_index('idx_sessions_user_id', 'sessions', ['user_id']) op.create_table( 'auth_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(30), nullable=False), 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(), server_default=sa.text('NOW()')), ) op.create_index('idx_auth_events_created', 'auth_events', ['created_at']) op.create_table( 'security_settings', sa.Column('key', sa.String(50), primary_key=True), sa.Column('value', sa.String(255), nullable=False), ) # ---------- Сид: роли, права, матрица, настройки ---------- conn = op.get_bind() roles = [('admin', 'РЦУ РЧС: все права, все операции'), ('coordinator', 'ОУМЧС: поиски своей области, управление Г(Р)ОЧС'), ('operator', 'Г(Р)ОЧС: свои поиски (create/update)'), ('observer', 'Только просмотр')] perms = [('view', 'Просмотр'), ('create', 'Создание'), ('update', 'Изменение'), ('delete', 'Удаление'), ('export', 'Экспорт'), ('manage_users', 'Управление пользователями'), ('manage_roles', 'Управление ролями'), ('view_audit', 'Просмотр аудита'), ('manage_security', 'Управление настройками безопасности')] matrix = { 'admin': tuple(p[0] for p in perms), 'coordinator': ('view', 'create', 'update', 'export', 'manage_users', 'view_audit'), 'operator': ('view', 'create', 'update'), 'observer': ('view',), } for name, desc in roles: conn.execute(sa.text( "INSERT INTO roles (name, description, is_system) VALUES (:n, :d, true) " "ON CONFLICT (name) DO NOTHING"), {'n': name, 'd': desc}) for code, desc in perms: conn.execute(sa.text( "INSERT INTO permissions (code, description) VALUES (:c, :d) " "ON CONFLICT (code) DO NOTHING"), {'c': code, 'd': desc}) for role_name, codes in matrix.items(): for code in codes: conn.execute(sa.text( "INSERT INTO role_permissions (role_id, permission_id) " "SELECT r.id, p.id FROM roles r, permissions p " "WHERE r.name = :rn AND p.code = :pc " "ON CONFLICT DO NOTHING"), {'rn': role_name, 'pc': code}) for k, v in { 'password_min_length': '8', 'password_require_complexity': 'true', 'password_expiry_days': '90', 'password_history_count': '5', 'max_failed_logins': '5', 'lockout_duration_minutes': '15', 'session_timeout_minutes': '1440', 'session_idle_timeout_minutes': '60', 'max_concurrent_sessions': '3', 'audit_retention_days': '90', }.items(): conn.execute(sa.text( "INSERT INTO security_settings (key, value) VALUES (:k, :v) " "ON CONFLICT (key) DO NOTHING"), {'k': k, 'v': v}) def downgrade() -> None: op.drop_table('security_settings') op.drop_index('idx_auth_events_created', table_name='auth_events') op.drop_table('auth_events') op.drop_index('idx_sessions_user_id', table_name='sessions') op.drop_table('sessions') op.drop_table('user_roles') op.drop_table('role_permissions') op.drop_table('permissions') op.drop_table('roles') for col in ('updated_at', 'created_by', 'must_change_password', 'locked_until', 'failed_login_count', 'status', 'position', 'unit_id'): op.drop_column('users', col) op.drop_table('mchs_units')