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