E1 (B21): RBAC-ядро — подразделения МЧС, роли/права, сессии, auth-журнал

- Модель данных: 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.
This commit is contained in:
2026-09-09 21:11:38 +03:00
parent 15e7e27cb8
commit 628a1d4536
6 changed files with 835 additions and 13 deletions
+109
View File
@@ -24,6 +24,16 @@ class User(Base):
created_at = Column(DateTime, server_default=func.now())
last_login = Column(DateTime)
# B21/E1 additions
unit_id = Column(UUID(as_uuid=True), ForeignKey("mchs_units.id", ondelete="SET NULL"))
position = Column(String(255))
status = Column(String(20), default="active") # active|locked|disabled
failed_login_count = Column(Integer, default=0)
locked_until = Column(DateTime(timezone=True))
must_change_password = Column(Boolean, default=False)
created_by = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"))
updated_at = Column(DateTime, server_default=func.now())
class Case(Base):
"""Unified case model - combines search case and result."""
@@ -225,3 +235,102 @@ class ReferencePrior(Base):
provenance = Column(Text)
version = Column(Integer, nullable=False, default=1)
created_at = Column(DateTime, server_default=func.now())
# ==================== B21/E1: RBAC-ядро (по образцу админки РВС) ====================
# Иерархия: РЦУ РЧС (rcu) → ОУМЧС (oblast) → Г(Р)ОЧС (gor_rayon)
class MchsUnit(Base):
"""Подразделение МЧС: РЦУ РЧС, ОУМЧС, Г(Р)ОЧС."""
__tablename__ = "mchs_units"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
name = Column(String(255), nullable=False, unique=True)
region = Column(String(100)) # область (для фильтров)
kind = Column(String(20), nullable=False) # rcu | oblast | gor_rayon
parent_id = Column(UUID(as_uuid=True), ForeignKey("mchs_units.id", ondelete="SET NULL"))
created_at = Column(DateTime, server_default=func.now())
__table_args__ = (
sa.CheckConstraint("kind IN ('rcu','oblast','gor_rayon')", name="ck_mchs_units_kind"),
)
class Role(Base):
"""Роль (is_system — сидовые, удаление запрещено)."""
__tablename__ = "roles"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
name = Column(String(50), nullable=False, unique=True)
description = Column(String(255))
is_system = Column(Boolean, default=False)
created_at = Column(DateTime, server_default=func.now())
class Permission(Base):
"""Атомарное право."""
__tablename__ = "permissions"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
code = Column(String(50), nullable=False, unique=True)
description = Column(String(255))
class RolePermission(Base):
"""M2M роль ↔ право."""
__tablename__ = "role_permissions"
role_id = Column(UUID(as_uuid=True), ForeignKey("roles.id", ondelete="CASCADE"), primary_key=True)
permission_id = Column(UUID(as_uuid=True), ForeignKey("permissions.id", ondelete="CASCADE"), primary_key=True)
class UserRole(Base):
"""M2M пользователь ↔ роль (с кем назначена)."""
__tablename__ = "user_roles"
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), primary_key=True)
role_id = Column(UUID(as_uuid=True), ForeignKey("roles.id", ondelete="CASCADE"), primary_key=True)
assigned_by = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"))
assigned_at = Column(DateTime, server_default=func.now())
class UserSession(Base):
"""Server-side сессия: JWT несёт session_id, отзыв возможен на стороне БД."""
__tablename__ = "sessions"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
token_hash = Column(String(64), nullable=False, unique=True) # sha256 jti
ip_address = Column(String(45))
user_agent = Column(String(255))
created_at = Column(DateTime, server_default=func.now())
expires_at = Column(DateTime(timezone=True), nullable=False)
last_seen_at = Column(DateTime(timezone=True))
is_active = Column(Boolean, default=True)
class AuthEvent(Base):
"""Журнал аутентификации: вход/выход/блокировка."""
__tablename__ = "auth_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)) # даже если user_id неизвестен
event_type = Column(String(30), nullable=False) # login|logout|login_failed|lockout|password_change
ip_address = Column(String(64))
user_agent = Column(String(255))
details = Column(JSONB)
created_at = Column(DateTime, server_default=func.now())
__table_args__ = (
sa.Index("idx_auth_events_created", "created_at"),
)
class SecuritySetting(Base):
"""Параметры безопасности (key/value)."""
__tablename__ = "security_settings"
key = Column(String(50), primary_key=True)
value = Column(String(255), nullable=False)