628a1d4536
- Модель данных: mchs_units (РЦУ РЧС → ОУМЧС → Г(Р)ОЧС, kind+parent_id), users +8 колонок (unit_id, position, status, lockout, must_change_password, created_by, updated_at), roles/permissions/role_permissions/user_roles (M2M матрица), sessions (server-side, token_hash=sha256(secret)), auth_events, security_settings. - Alembic 007_e1_rbac: схема + сид 4 ролей / 9 прав / матрицы (9/6/3/1) / 10 настроек безопасности; downgrade → upgrade идемпотентен (проверено на pg16-контейнере, сид не дублируется). - auth.py: login с lockout (5 неудач → 15 мин) и auth-журналом; server-side сессии (JWT несёт secret, в БД — sha256-хэш; max_concurrent_sessions); /me с permissions (user_roles → role_permissions, fallback legacy-роль); /change-password (мин. длина из settings, отзыв сессий); /logout. require_permission(code) — атомарные права (fallback legacy-роли). - Обратная совместимость: require_roles не тронут, старые JWT без sid продолжают работать. - Тесты E1 (9): login/logout/lockout/me-права/смена пароля/журнал. SQLite in-memory (StaticPool + компиляторы UUID/JSONB/ARRAY). 202 passed, 5 skipped.
167 lines
8.4 KiB
Python
167 lines
8.4 KiB
Python
"""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') |