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
+167
View File
@@ -0,0 +1,167 @@
"""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')
+109
View File
@@ -24,6 +24,16 @@ class User(Base):
created_at = Column(DateTime, server_default=func.now()) created_at = Column(DateTime, server_default=func.now())
last_login = Column(DateTime) 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): class Case(Base):
"""Unified case model - combines search case and result.""" """Unified case model - combines search case and result."""
@@ -225,3 +235,102 @@ class ReferencePrior(Base):
provenance = Column(Text) provenance = Column(Text)
version = Column(Integer, nullable=False, default=1) version = Column(Integer, nullable=False, default=1)
created_at = Column(DateTime, server_default=func.now()) 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)
+70
View File
@@ -0,0 +1,70 @@
"""E1 (B21): RBAC-ядро — подразделения МЧС, роли/права, сессии, auth-журнал.
Иерархия подразделений: РЦУ РЧС (rcu) → ОУМЧС (oblast) → Г(Р)ОЧС (gor_rayon).
Роли — как в РВС-админке (is_system), права атомарные, user_roles M2M.
Сессии server-side: JWT несёт session_id, каждый запрос проверяет сессию
в БД (отзыв по блокировке пользователя/окончании срока).
Модели добавлены в backend/models.py; здесь — только константы (seed-данные)
и хелперы прав/скоупа.
"""
from __future__ import annotations
from sqlalchemy import text
# ---- Иерархия подразделений ----
UNIT_KINDS = ('rcu', 'oblast', 'gor_rayon')
# ---- Роли (is_system) ----
SYSTEM_ROLES = (
'admin', # РЦУ РЧС: все права, все операции
'coordinator', # ОУМЧС: поиски своей области, управление подчинёнными Г(Р)ОЧС
'operator', # Г(Р)ОЧС: свои поиски (create/update)
'observer', # только просмотр
)
# ---- Атомарные права ----
PERMISSIONS = (
'view', 'create', 'update', 'delete', 'export',
'manage_users', 'manage_roles', 'view_audit', 'manage_security',
)
ROLE_PERMISSIONS: dict[str, tuple[str, ...]] = {
'admin': PERMISSIONS,
'coordinator': ('view', 'create', 'update', 'export', 'manage_users', 'view_audit'),
'operator': ('view', 'create', 'update'),
'observer': ('view',),
}
# Роли-совместимость со старым единым role-полем: admin/coordinator/operator/observer
# покрывают прежние operator/field/admin (field ≈ operator: полевой доступ).
# ---- Политика безопасности (security_settings) ----
SECURITY_DEFAULTS = {
'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',
}
def descendant_unit_ids(conn, unit_id: str) -> list[str]:
"""Все id подразделения + его потомков (для гео-скоупа). Рекурсивный CTE."""
rows = conn.execute(
text("""
WITH RECURSIVE tree AS (
SELECT id FROM mchs_units WHERE id = :uid
UNION ALL
SELECT u.id FROM mchs_units u JOIN tree t ON u.parent_id = t.id
)
SELECT id FROM tree
"""),
{'unit_id': unit_id},
).fetchall()
return [str(r[0]) for r in rows]
+282 -13
View File
@@ -1,19 +1,21 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime, timedelta import hashlib
import os
from datetime import datetime, timedelta, timezone
from types import SimpleNamespace from types import SimpleNamespace
from typing import Optional from typing import Optional
import os
import bcrypt import bcrypt
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer, OAuth2PasswordRequestForm from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer, OAuth2PasswordRequestForm
from jose import JWTError, jwt from jose import JWTError, jwt
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy import text
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from backend.database import get_db from backend.database import get_db
from backend.models import User from backend.models import AuthEvent, SecuritySetting, User, UserSession
router = APIRouter(prefix='/api/v1/auth', tags=['auth']) router = APIRouter(prefix='/api/v1/auth', tags=['auth'])
security = HTTPBearer(auto_error=False) security = HTTPBearer(auto_error=False)
@@ -33,15 +35,24 @@ class UserPublic(BaseModel):
full_name: str | None = None full_name: str | None = None
role: str role: str
is_active: bool is_active: bool
# B21/E1
unit_id: str | None = None
position: str | None = None
permissions: list[str] = []
must_change_password: bool = False
@classmethod @classmethod
def from_orm_user(cls, user: User) -> 'UserPublic': def from_orm_user(cls, user: User, permissions: list[str] | None = None) -> 'UserPublic':
return cls( return cls(
username=user.username, username=user.username,
email=user.email, email=user.email,
full_name=user.full_name, full_name=user.full_name,
role=user.role, role=user.role,
is_active=user.is_active, is_active=user.is_active,
unit_id=str(user.unit_id) if getattr(user, 'unit_id', None) else None,
position=getattr(user, 'position', None),
permissions=permissions or [],
must_change_password=bool(getattr(user, 'must_change_password', False)),
) )
@@ -55,7 +66,7 @@ def get_password_hash(password: str) -> str:
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str: def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
to_encode = data.copy() to_encode = data.copy()
expire = datetime.utcnow() + (expires_delta or timedelta(minutes=15)) expire = datetime.now(timezone.utc) + (expires_delta or timedelta(minutes=15))
to_encode.update({'exp': expire}) to_encode.update({'exp': expire})
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
@@ -69,6 +80,137 @@ def authenticate_user(db: Session, username: str, password: str) -> User | None:
return user return user
def _client_meta(request: Request | None) -> tuple[str | None, str | None]:
if request is None:
return None, None
return request.client.host if request.client else None, \
(request.headers.get('user-agent') or '')[:255]
def _log_auth(db: Session, event_type: str, username: str | None,
user_id=None, request: Request | None = None, details: dict | None = None) -> None:
ip, ua = _client_meta(request)
db.add(AuthEvent(
user_id=user_id, username=username, event_type=event_type,
ip_address=ip, user_agent=ua, details=details or {},
))
def _get_setting(db: Session, key: str, default: str) -> str:
row = db.query(SecuritySetting).filter(SecuritySetting.key == key).first()
return row.value if row else default
def _aware(dt: datetime) -> datetime:
"""SQLite отдаёт naive datetimes — приводим к UTC-aware для арифметики."""
if dt.tzinfo is None:
return dt.replace(tzinfo=timezone.utc)
return dt
def _check_lockout(db: Session, user: User) -> int:
"""Секунды до разблокировки, либо 0 если не заблокирован."""
if getattr(user, 'locked_until', None):
remaining = (_aware(user.locked_until) - datetime.now(timezone.utc)).total_seconds()
if remaining > 0:
return int(remaining)
# Срок вышел — сбрасываем
user.failed_login_count = 0
user.locked_until = None
db.commit()
return 0
def _register_failed_login(db: Session, user: User, request: Request | None) -> None:
max_failed = int(_get_setting(db, 'max_failed_logins', '5'))
lockout_min = int(_get_setting(db, 'lockout_duration_minutes', '15'))
user.failed_login_count = (user.failed_login_count or 0) + 1
if user.failed_login_count >= max_failed:
user.locked_until = datetime.now(timezone.utc) + timedelta(minutes=lockout_min)
_log_auth(db, 'lockout', user.username, user.id, request,
{'failed_count': user.failed_login_count})
else:
_log_auth(db, 'login_failed', user.username, user.id, request,
{'failed_count': user.failed_login_count})
db.commit()
def _create_session(db: Session, user: User, request: Request | None,
expires_delta: timedelta) -> tuple[str, str]:
"""Server-side сессия. Возвращает (secret_for_jwt, hash_in_db)."""
max_sessions = int(_get_setting(db, 'max_concurrent_sessions', '3'))
# Лимит параллельных сессий: гасим самые старые активные
active = (db.query(UserSession)
.filter(UserSession.user_id == user.id, UserSession.is_active == True) # noqa: E712
.order_by(UserSession.created_at.asc()).all())
while len(active) >= max_sessions:
oldest = active.pop(0)
oldest.is_active = False
secret = os.urandom(32).hex() # уходит в JWT (jti)
raw = hashlib.sha256(secret.encode('utf-8')).hexdigest() # хэш в БД
ip, ua = _client_meta(request)
now = datetime.now(timezone.utc)
sess = UserSession(
user_id=user.id,
token_hash=raw,
ip_address=ip,
user_agent=ua,
expires_at=now + expires_delta,
last_seen_at=now,
is_active=True,
)
db.add(sess)
db.commit()
db.refresh(sess)
return secret, raw
def _session_token_hash(raw: str) -> str:
"""token_hash в БД = sha256(session_secret); в JWT уходит сам secret (jti)."""
return hashlib.sha256(raw.encode('utf-8')).hexdigest()
def _session_active(db: Session, user: User, raw: str):
"""True, если сессия активна и не истекла."""
sess = (db.query(UserSession)
.filter(UserSession.user_id == user.id,
UserSession.token_hash == _session_token_hash(raw),
UserSession.is_active == True).first()) # noqa: E712
if not sess:
return False
if sess.expires_at and _aware(sess.expires_at) < datetime.now(timezone.utc):
sess.is_active = False
db.commit()
return False
sess.last_seen_at = datetime.now(timezone.utc)
db.commit()
return True
def user_permissions(db: Session, user: User) -> list[str]:
"""Атомарные права пользователя: через user_roles → role_permissions.
Fallback на legacy-роль, если RBAC-таблицы пусты (старые seed-пользователи)."""
codes = []
try:
rows = (db.execute(
text("""
SELECT DISTINCT p.code
FROM user_roles ur
JOIN roles r ON r.id = ur.role_id
JOIN role_permissions rp ON rp.role_id = r.id
JOIN permissions p ON p.id = rp.permission_id
WHERE ur.user_id = :uid
"""), {'uid': str(user.id)}).fetchall())
codes = [r[0] for r in rows]
except Exception:
codes = []
if codes:
return sorted(codes)
# Legacy fallback: роль пользователя → права
from backend.rbac import ROLE_PERMISSIONS
return sorted(ROLE_PERMISSIONS.get(user.role, ()))
def _test_user() -> SimpleNamespace: def _test_user() -> SimpleNamespace:
return SimpleNamespace( return SimpleNamespace(
id='test-user', id='test-user',
@@ -78,9 +220,19 @@ def _test_user() -> SimpleNamespace:
role='admin', role='admin',
is_active=True, is_active=True,
last_login=None, last_login=None,
unit_id=None,
position=None,
status='active',
must_change_password=False,
failed_login_count=0,
locked_until=None,
) )
def _looks_like_test_user(user: User) -> bool:
return getattr(user, 'unit_id', None) is None and os.getenv('PYTEST_CURRENT_TEST')
async def get_current_user( async def get_current_user(
credentials: HTTPAuthorizationCredentials | None = Depends(security), credentials: HTTPAuthorizationCredentials | None = Depends(security),
db: Session = Depends(get_db), db: Session = Depends(get_db),
@@ -124,6 +276,29 @@ async def get_current_user(
detail='Could not validate credentials', detail='Could not validate credentials',
headers={'WWW-Authenticate': 'Bearer'}, headers={'WWW-Authenticate': 'Bearer'},
) )
# B21/E1: блокировка/статус
user_status = getattr(user, 'status', 'active')
if user_status in ('locked', 'disabled'):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='Account is locked or disabled',
)
if _check_lockout(db, user) > 0:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='Account is temporarily locked',
)
# Server-side сессия: если токен несёт sid — проверяем активность
sid = payload.get('sid')
if sid and not _session_active(db, user, sid):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail='Session expired or revoked',
headers={'WWW-Authenticate': 'Bearer'},
)
return user return user
@@ -139,29 +314,123 @@ def require_roles(allowed_roles: list[str]):
return checker return checker
def require_permission(code: str):
"""E1: атомарное право через RBAC (fallback на legacy-роль admin)."""
async def checker(current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)) -> User:
if code in user_permissions(db, current_user):
return current_user
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f'Access denied. Required permission: {code}',
)
return checker
@router.post('/login', response_model=Token) @router.post('/login', response_model=Token)
def login( def login(
request: Request,
form_data: OAuth2PasswordRequestForm = Depends(), form_data: OAuth2PasswordRequestForm = Depends(),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> dict[str, str]: ) -> dict[str, str]:
user = authenticate_user(db, form_data.username, form_data.password) user = db.query(User).filter(User.username == form_data.username).first()
if not user:
if user and getattr(user, 'status', 'active') in ('locked', 'disabled'):
_log_auth(db, 'login_failed', user.username, user.id, request,
{'reason': 'account_status'})
db.commit()
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='Account is locked or disabled',
)
auth_ok = user is not None and verify_password(form_data.password, user.hashed_password)
if not auth_ok:
if user is not None:
_register_failed_login(db, user, request)
else:
_log_auth(db, 'login_failed', form_data.username, None, request)
db.commit()
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail='Incorrect username or password', detail='Incorrect username or password',
headers={'WWW-Authenticate': 'Bearer'}, headers={'WWW-Authenticate': 'Bearer'},
) )
user.last_login = datetime.utcnow() lock_left = _check_lockout(db, user)
if lock_left > 0:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f'Account is locked. Try again in {lock_left // 60 + 1} min',
)
expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
sid_secret, _sid_hash = _create_session(db, user, request, expires)
user.failed_login_count = 0
user.last_login = datetime.now(timezone.utc)
_log_auth(db, 'login', user.username, user.id, request)
db.commit() db.commit()
token = create_access_token( token = create_access_token(
{'sub': user.username, 'role': user.role}, {'sub': user.username, 'role': user.role, 'sid': sid_secret},
timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES), expires,
) )
return {'access_token': token, 'token_type': 'bearer'} return {'access_token': token, 'token_type': 'bearer'}
@router.get('/me', response_model=UserPublic) @router.get('/me', response_model=UserPublic)
def me(current_user: User = Depends(get_current_user)) -> UserPublic: def me(current_user: User = Depends(get_current_user), db: Session = Depends(get_db)) -> UserPublic:
return UserPublic.from_orm_user(current_user) return UserPublic.from_orm_user(current_user, permissions=user_permissions(db, current_user))
class PasswordChange(BaseModel):
current_password: str
new_password: str
@router.post('/change-password')
def change_password(
payload: PasswordChange,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
) -> dict[str, str]:
min_len = int(_get_setting(db, 'password_min_length', '8'))
if not verify_password(payload.current_password, current_user.hashed_password):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Текущий пароль неверен')
if len(payload.new_password) < min_len:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Минимальная длина пароля: {min_len} символов',
)
current_user.hashed_password = get_password_hash(payload.new_password)
current_user.must_change_password = False
current_user.updated_at = datetime.now(timezone.utc)
# Отзываем все сессии после смены пароля (кроме текущей — её пересоздадим)
(db.query(UserSession)
.filter(UserSession.user_id == current_user.id, UserSession.is_active == True)
.update({'is_active': False})) # noqa: E712
_log_auth(db, 'password_change', current_user.username, current_user.id)
db.commit()
return {'status': 'ok', 'message': 'Пароль изменён; войдите заново'}
@router.post('/logout')
def logout(
credentials: HTTPAuthorizationCredentials | None = Depends(security),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> dict[str, str]:
try:
payload = jwt.decode(credentials.credentials, SECRET_KEY, algorithms=[ALGORITHM])
sid = payload.get('sid')
if sid:
(db.query(UserSession)
.filter(UserSession.user_id == current_user.id,
UserSession.token_hash == _session_token_hash(sid))
.update({'is_active': False}))
except JWTError:
pass
_log_auth(db, 'logout', current_user.username, current_user.id)
db.commit()
return {'status': 'ok'}
+198
View File
@@ -0,0 +1,198 @@
"""E1 (B21): тесты RBAC-ядра — сессии, блокировка, права, журнал.
SQLite in-memory с RBAC-таблицами; UUID/JSONB подменяются компиляторами.
"""
from __future__ import annotations
import sys
from pathlib import Path
import bcrypt
import pytest
REPO_ROOT = Path(__file__).resolve().parents[2]
if str(REPO_ROOT) not in sys.path:
sys.path.insert(0, str(REPO_ROOT))
import sqlalchemy
from sqlalchemy import create_engine
from sqlalchemy.dialects.postgresql import JSONB, UUID as PG_UUID
from sqlalchemy.ext.compiler import compiles
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
@compiles(PG_UUID, 'sqlite')
def _uuid_sqlite(type_, compiler, **kw):
return 'CHAR(36)'
@compiles(JSONB, 'sqlite')
def _jsonb_sqlite(type_, compiler, **kw):
return 'JSON'
@compiles(sqlalchemy.ARRAY, 'sqlite')
def _array_sqlite(type_, compiler, **kw):
return 'TEXT'
@compiles(sqlalchemy.ARRAY, 'sqlite')
def _array_sqlite(type_, compiler, **kw):
return 'TEXT'
@pytest.fixture()
def rbac_env():
from backend import models as m
from backend.database import Base
from backend.models import (
MchsUnit,
Permission,
Role,
RolePermission,
SecuritySetting,
UserRole,
)
engine = create_engine(
'sqlite:///:memory:',
connect_args={'check_same_thread': False},
poolclass=StaticPool, # одна connection на все threads (иначе per-thread БД)
)
Base.metadata.create_all(engine)
TestingSession = sessionmaker(bind=engine)
db = TestingSession()
unit = MchsUnit(name='Минское Г(Р)ОЧС', region='Минская', kind='gor_rayon')
rcu = MchsUnit(name='РЦУ РЧС', region='Республика', kind='rcu')
db.add_all([unit, rcu])
db.flush()
def _hash(pw: str) -> str:
return bcrypt.hashpw(pw.encode(), bcrypt.gensalt()).decode()
user = m.User(username='op1', email='op1@test.by', hashed_password=_hash('oldpass123'),
full_name='Оператор Г(Р)ОЧС', role='operator', is_active=True,
unit_id=unit.id)
admin = m.User(username='root_rcu', email='root@test.by', hashed_password=_hash('rootpass123'),
full_name='РЦУ РЧС', role='admin', is_active=True, unit_id=rcu.id)
db.add_all([user, admin])
db.commit()
roles = {}
for name, desc in [('admin', ''), ('coordinator', ''), ('operator', ''), ('observer', '')]:
r = Role(name=name, description=desc, is_system=True)
db.add(r)
roles[name] = r
perms = {}
for code in ('view', 'create', 'update', 'delete', 'export', 'manage_users',
'manage_roles', 'view_audit', 'manage_security'):
p = Permission(code=code)
db.add(p)
perms[code] = p
db.commit()
matrix = {
'admin': tuple(perms),
'coordinator': ('view', 'create', 'update', 'export', 'manage_users', 'view_audit'),
'operator': ('view', 'create', 'update'),
'observer': ('view',),
}
for rn, codes in matrix.items():
for c in codes:
db.add(RolePermission(role_id=roles[rn].id, permission_id=perms[c].id))
db.add(UserRole(user_id=user.id, role_id=roles['operator'].id))
db.commit()
from backend.rbac import SECURITY_DEFAULTS
for k, v in SECURITY_DEFAULTS.items():
db.add(SecuritySetting(key=k, value=v))
db.commit()
from backend.main import app
from backend.routers import auth as auth_router
from fastapi.testclient import TestClient
def override_get_db():
s = TestingSession()
try:
yield s
finally:
s.close()
app.dependency_overrides[auth_router.get_db] = override_get_db
client = TestClient(app)
yield {'db': db, 'client': client, 'user': user, 'admin': admin}
app.dependency_overrides.pop(auth_router.get_db, None)
def _login(client, username: str, password: str):
return client.post('/api/v1/auth/login', data={'username': username, 'password': password})
class TestLogin:
def test_login_creates_session_and_token(self, rbac_env):
r = _login(rbac_env['client'], 'op1', 'oldpass123')
assert r.status_code == 200
assert 'access_token' in r.json()
def test_wrong_password_fails(self, rbac_env):
assert _login(rbac_env['client'], 'op1', 'wrong').status_code == 401
def test_lockout_after_max_failures(self, rbac_env):
c = rbac_env['client']
for _ in range(5):
c.post('/api/v1/auth/login', data={'username': 'op1', 'password': 'wrong'})
r = _login(c, 'op1', 'oldpass123')
assert r.status_code == 403
assert 'locked' in r.json()['detail'].lower()
def test_me_has_permissions(self, rbac_env):
c = rbac_env['client']
tok = _login(c, 'op1', 'oldpass123').json()['access_token']
me = c.get('/api/v1/auth/me', headers={'Authorization': f'Bearer {tok}'}).json()
assert me['permissions'] == ['create', 'update', 'view']
assert me['unit_id'] is not None
def test_admin_permissions_full(self, rbac_env):
c = rbac_env['client']
tok = _login(c, 'root_rcu', 'rootpass123').json()['access_token']
me = c.get('/api/v1/auth/me', headers={'Authorization': f'Bearer {tok}'}).json()
assert 'manage_users' in me['permissions']
assert len(me['permissions']) == 9
def test_logout_revokes_session(self, rbac_env):
c = rbac_env['client']
tok = _login(c, 'op1', 'oldpass123').json()['access_token']
h = {'Authorization': f'Bearer {tok}'}
assert c.get('/api/v1/auth/me', headers=h).status_code == 200
c.post('/api/v1/auth/logout', headers=h)
assert c.get('/api/v1/auth/me', headers=h).status_code == 401
def test_change_password_revokes_sessions(self, rbac_env):
c = rbac_env['client']
tok = _login(c, 'op1', 'oldpass123').json()['access_token']
h = {'Authorization': f'Bearer {tok}'}
r = c.post('/api/v1/auth/change-password', headers=h,
json={'current_password': 'oldpass123', 'new_password': 'newpass456'})
assert r.status_code == 200
assert c.get('/api/v1/auth/me', headers=h).status_code == 401
assert _login(c, 'op1', 'newpass456').status_code == 200
def test_short_password_rejected(self, rbac_env):
c = rbac_env['client']
tok = _login(c, 'op1', 'oldpass123').json()['access_token']
h = {'Authorization': f'Bearer {tok}'}
r = c.post('/api/v1/auth/change-password', headers=h,
json={'current_password': 'oldpass123', 'new_password': 'abc'})
assert r.status_code == 400
def test_auth_events_logged(self, rbac_env):
from backend.models import AuthEvent
c = rbac_env['client']
_login(c, 'op1', 'oldpass123')
_login(c, 'op1', 'WRONG')
types = {e.event_type for e in rbac_env['db'].query(AuthEvent).all()}
assert 'login' in types and 'login_failed' in types
+9
View File
@@ -0,0 +1,9 @@
-- Верификация 007: таблицы, сид ролей/прав/матрицы
SELECT 'roles' AS t, count(*) FROM roles
UNION ALL SELECT 'permissions', count(*) FROM permissions
UNION ALL SELECT 'role_permissions', count(*) FROM role_permissions
UNION ALL SELECT 'security_settings', count(*) FROM security_settings
UNION ALL SELECT 'users_new_cols', count(*) FROM information_schema.columns WHERE table_name='users' AND column_name IN ('unit_id','status','failed_login_count','locked_until','must_change_password','created_by','updated_at','position');
SELECT r.name, count(rp.permission_id) AS perms FROM roles r LEFT JOIN role_permissions rp ON rp.role_id = r.id GROUP BY r.name ORDER BY r.name;
SELECT name, kind FROM mchs_units;
SELECT version_num FROM alembic_version;