feat(admin): пользователи, роли, матрица прав — API с аудитом и защитой LAST_ADMIN
/api/v1/admin/* под правом USERS_ADMIN (403 на всё без него): список пользователей (поиск по email/ФИО, is_active, Page), создание (email уникален без учёта регистра → 409 EMAIL_EXISTS, роли — коды активных ролей → 422 UNKNOWN_ROLE), PATCH полей и деактивация, PUT ролей (замена набора с diff), GET ролей с правами, GET справочника прав (16 кодов), PUT прав роли (422 UNKNOWN_PERMISSION, granted_by/granted_at). Аудит USER_CHANGE / USER_ROLE_CHANGE (USERS) и ROLE_PERMISSION_CHANGE (ACCESS); повтор того же состояния аудит не пишет. LAST_ADMIN: деактивация (в том числе себя), замена ролей и снятие USERS_ADMIN с роли запрещены, если у активных пользователей через активные роли не останется USERS_ADMIN. Гонки: строка пользователя/роли и строка права USERS_ADMIN блокируются FOR NO KEY UPDATE (FOR UPDATE давал deadlock на FK-проверке вставки audit_event); тест двух параллельных транзакций с барьером. Задача плана Task 12, tasks.md 4.1. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
This commit is contained in:
parent
4dd966b0b7
commit
800f3a49c4
23
api/src/api/dependencies/admin.py
Normal file
23
api/src/api/dependencies/admin.py
Normal file
@ -0,0 +1,23 @@
|
||||
"""Зависимости API администрирования (spec users-admin): право `USERS_ADMIN` и сервис (R-6: сессия — `SessionDep`)."""
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Depends
|
||||
|
||||
from src.api.dependencies.audit import RequestAudit, get_request_audit
|
||||
from src.api.dependencies.auth import require
|
||||
from src.api.dependencies.db import SessionDep
|
||||
from src.services.users_admin import USERS_ADMIN, UsersAdminService
|
||||
|
||||
# Право на все маршруты `/admin/*`: без него — 403 до любых проверок тела и существования объектов
|
||||
ADMIN_ONLY = [Depends(require(USERS_ADMIN))]
|
||||
|
||||
|
||||
def get_users_admin_service(
|
||||
session: SessionDep,
|
||||
audit: Annotated[RequestAudit, Depends(get_request_audit)],
|
||||
) -> UsersAdminService:
|
||||
return UsersAdminService(session, audit, audit.user_id)
|
||||
|
||||
|
||||
UsersAdminServiceDep = Annotated[UsersAdminService, Depends(get_users_admin_service)]
|
||||
10
api/src/api/endpoints/admin/__init__.py
Normal file
10
api/src/api/endpoints/admin/__init__.py
Normal file
@ -0,0 +1,10 @@
|
||||
"""Маршруты администрирования `/api/v1/admin/*` (spec users-admin): все требуют право `USERS_ADMIN`."""
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from src.api.dependencies.admin import ADMIN_ONLY
|
||||
from src.api.endpoints.admin import roles, users
|
||||
|
||||
router = APIRouter(prefix="/admin", tags=["admin"], dependencies=ADMIN_ONLY)
|
||||
router.include_router(users.router)
|
||||
router.include_router(roles.router)
|
||||
42
api/src/api/endpoints/admin/roles.py
Normal file
42
api/src/api/endpoints/admin/roles.py
Normal file
@ -0,0 +1,42 @@
|
||||
"""Матрица прав `/admin/roles`, `/admin/permissions` (spec users-admin): чтение и замена набора прав роли.
|
||||
|
||||
Права — справочник только для чтения (новые коды не создаются); роли не создаются, не переименовываются и не
|
||||
деактивируются через API (системные роли — `is_system`, D-8).
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from src.api.dependencies.admin import UsersAdminServiceDep
|
||||
from src.models.access import Role
|
||||
from src.schemas.admin import PermissionOut, PermissionsSet, RoleOut
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _out(role: Role, permissions: list[str]) -> RoleOut:
|
||||
return RoleOut(
|
||||
code=role.code,
|
||||
name=role.name,
|
||||
description=role.description,
|
||||
is_system=role.is_system,
|
||||
is_active=role.is_active,
|
||||
permissions=permissions,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/roles", response_model=list[RoleOut], summary="Роли с правами")
|
||||
async def list_roles(service: UsersAdminServiceDep) -> list[RoleOut]:
|
||||
return [_out(role, permissions) for role, permissions in await service.list_roles()]
|
||||
|
||||
|
||||
@router.get("/permissions", response_model=list[PermissionOut], summary="Справочник прав")
|
||||
async def list_permissions(service: UsersAdminServiceDep) -> list[PermissionOut]:
|
||||
return [PermissionOut.model_validate(permission) for permission in await service.list_permissions()]
|
||||
|
||||
|
||||
@router.put("/roles/{code}/permissions", response_model=RoleOut, summary="Заменить права роли")
|
||||
async def set_role_permissions(code: str, data: PermissionsSet, service: UsersAdminServiceDep) -> RoleOut:
|
||||
"""Неизвестная роль — 404; неизвестный код права — 422 `UNKNOWN_PERMISSION`; снятие последнего `USERS_ADMIN` —
|
||||
409 `LAST_ADMIN`."""
|
||||
await service.set_role_permissions(code, data.permissions)
|
||||
return _out(*await service.role_view(code))
|
||||
66
api/src/api/endpoints/admin/users.py
Normal file
66
api/src/api/endpoints/admin/users.py
Normal file
@ -0,0 +1,66 @@
|
||||
"""Пользователи `/admin/users` (spec users-admin): список, создание, изменение/деактивация, замена ролей.
|
||||
|
||||
Удаления нет (D-8): пользователь только деактивируется (`is_active=false`). Неизвестный id — 404.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Query, status
|
||||
|
||||
from src.api.dependencies.admin import UsersAdminServiceDep
|
||||
from src.schemas.admin import RolesSet, UserCreate, UserListQuery, UserOut, UserUpdate
|
||||
from src.schemas.common import Page, PageParams
|
||||
from src.schemas.directories import RegionalOfficeOut
|
||||
from src.services.users_admin import UserWithRoles
|
||||
|
||||
router = APIRouter(prefix="/users")
|
||||
|
||||
|
||||
class UserListParams(UserListQuery, PageParams):
|
||||
"""Query списка пользователей: фильтры и пагинация одной моделью (FastAPI поддерживает одну Query-модель)."""
|
||||
|
||||
|
||||
def _out(view: UserWithRoles) -> UserOut:
|
||||
user, office, roles = view
|
||||
return UserOut(
|
||||
id=user.id,
|
||||
email=user.email,
|
||||
full_name=user.full_name,
|
||||
employee_no=user.employee_no,
|
||||
regional_office=RegionalOfficeOut.model_validate(office),
|
||||
business_segment=user.business_segment,
|
||||
is_active=user.is_active,
|
||||
roles=roles,
|
||||
created_at=user.created_at,
|
||||
updated_at=user.updated_at,
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=Page[UserOut], summary="Список пользователей")
|
||||
async def list_users(service: UsersAdminServiceDep, query: Annotated[UserListParams, Query()]) -> Page[UserOut]:
|
||||
"""Пользователи с филиалом и ролями: поиск `q` по email/ФИО, фильтр `is_active`, сортировка по ФИО."""
|
||||
page = PageParams(limit=query.limit, offset=query.offset)
|
||||
rows, total = await service.list_users(query.q, query.is_active, page)
|
||||
return Page[UserOut](items=[_out(row) for row in rows], total=total, limit=page.limit, offset=page.offset)
|
||||
|
||||
|
||||
@router.post("", response_model=UserOut, status_code=status.HTTP_201_CREATED, summary="Создать пользователя")
|
||||
async def create_user(data: UserCreate, service: UsersAdminServiceDep) -> UserOut:
|
||||
"""Email уникален без учёта регистра (409 `EMAIL_EXISTS`); роли — коды активных ролей (иначе 422 `UNKNOWN_ROLE`)."""
|
||||
user = await service.create_user(data)
|
||||
return _out(await service.user_view(user.id))
|
||||
|
||||
|
||||
@router.patch("/{user_id}", response_model=UserOut, summary="Изменить или деактивировать пользователя")
|
||||
async def update_user(user_id: uuid.UUID, data: UserUpdate, service: UsersAdminServiceDep) -> UserOut:
|
||||
"""Деактивация последнего активного обладателя `USERS_ADMIN` (и себя) — 409 `LAST_ADMIN`."""
|
||||
user = await service.update_user(user_id, data)
|
||||
return _out(await service.user_view(user.id))
|
||||
|
||||
|
||||
@router.put("/{user_id}/roles", response_model=UserOut, summary="Заменить роли пользователя")
|
||||
async def set_user_roles(user_id: uuid.UUID, data: RolesSet, service: UsersAdminServiceDep) -> UserOut:
|
||||
"""Набор ролей заменяется целиком; снятие последнего источника `USERS_ADMIN` — 409 `LAST_ADMIN`."""
|
||||
await service.set_roles(user_id, data.roles)
|
||||
return _out(await service.user_view(user_id))
|
||||
@ -1,12 +1,13 @@
|
||||
"""Корневой роутер API `/api/v1` (R-6: эндпоинты — `src/api/endpoints/`).
|
||||
|
||||
`build_api_router(settings)` собирает роутер под настройки: `/me` и `/directories/*` — всегда, dev-эмитент токенов —
|
||||
только при `AUTH_DEV_ISSUER and DEBUG`. Модульный `router` собран по настройкам процесса и подключается в `src/app.py`.
|
||||
`build_api_router(settings)` собирает роутер под настройки: `/me`, `/directories/*` и `/admin/*` — всегда,
|
||||
dev-эмитент токенов — только при `AUTH_DEV_ISSUER and DEBUG`. Модульный `router` собран по настройкам процесса и
|
||||
подключается в `src/app.py`.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from src.api.endpoints import directories, me
|
||||
from src.api.endpoints import admin, directories, me
|
||||
from src.api.endpoints.dev_auth import build_dev_router
|
||||
from src.core.config import Settings, get_settings
|
||||
|
||||
@ -18,6 +19,7 @@ def build_api_router(settings: Settings) -> APIRouter:
|
||||
api_router = APIRouter(prefix=API_PREFIX)
|
||||
api_router.include_router(me.router)
|
||||
api_router.include_router(directories.router)
|
||||
api_router.include_router(admin.router)
|
||||
if settings.AUTH_DEV_ISSUER and settings.DEBUG:
|
||||
api_router.include_router(build_dev_router(settings))
|
||||
return api_router
|
||||
|
||||
@ -1,17 +1,23 @@
|
||||
"""Репозиторий пользователей: личность и эффективные права одним запросом (spec authn-authz, D-5, R-17).
|
||||
|
||||
`effective_permissions(user) = ∪ permission.code по всем активным ролям пользователя` (CLAUDE.md: права — данные).
|
||||
Код роли в проверках не участвует: роли возвращаются только для показа (`GET /me`).
|
||||
Код роли в проверках не участвует: роли возвращаются только для показа (`GET /me`, список администратора).
|
||||
Запросы администрирования (spec users-admin): списки пользователей/ролей/прав, назначения, подсчёт обладателей права.
|
||||
Функции не делают `commit`: транзакцией запроса управляет `SessionDep`.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from collections.abc import Collection, Sequence
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import and_, func, select
|
||||
from sqlalchemy import Select, and_, delete, func, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import aliased
|
||||
|
||||
from src.models.access import AppUser, Permission, Role, RolePermission, UserRole
|
||||
from src.models.directories import RegionalOffice
|
||||
from src.repositories.directories import LIKE_ESCAPE, escape_like
|
||||
|
||||
# Достаточно двух строк, чтобы заметить неоднозначность; больше пользователей читать незачем
|
||||
_AMBIGUITY_PROBE_LIMIT = 2
|
||||
@ -71,3 +77,185 @@ async def load_principal_data(session: AsyncSession, email: str) -> UserWithAcce
|
||||
roles=tuple(sorted(roles)),
|
||||
permissions=permissions,
|
||||
)
|
||||
|
||||
|
||||
# --- Администрирование (spec users-admin, D-8) ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def _filtered_users(stmt: Select[Any], q: str | None, is_active: bool | None) -> Select[Any]:
|
||||
if q is not None and q.strip():
|
||||
pattern = f"%{escape_like(q.strip())}%"
|
||||
stmt = stmt.where(
|
||||
or_(AppUser.email.ilike(pattern, escape=LIKE_ESCAPE), AppUser.full_name.ilike(pattern, escape=LIKE_ESCAPE))
|
||||
)
|
||||
if is_active is not None:
|
||||
stmt = stmt.where(AppUser.is_active.is_(is_active))
|
||||
return stmt
|
||||
|
||||
|
||||
async def list_users(
|
||||
session: AsyncSession, *, q: str | None, is_active: bool | None, limit: int, offset: int
|
||||
) -> tuple[list[tuple[AppUser, RegionalOffice]], int]:
|
||||
"""Страница пользователей с филиалом (по ФИО, затем id) и общее число по фильтру."""
|
||||
page_stmt = (
|
||||
_filtered_users(select(AppUser, RegionalOffice), q, is_active)
|
||||
.join(RegionalOffice, RegionalOffice.id == AppUser.regional_office_id)
|
||||
.order_by(AppUser.full_name, AppUser.id)
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
)
|
||||
total_stmt = _filtered_users(select(func.count(AppUser.id)), q, is_active)
|
||||
rows = (await session.execute(page_stmt)).all()
|
||||
total = (await session.execute(total_stmt)).scalar_one()
|
||||
return [(user, office) for user, office in rows], total
|
||||
|
||||
|
||||
async def get_user(session: AsyncSession, user_id: uuid.UUID) -> AppUser | None:
|
||||
"""Пользователь по id."""
|
||||
return await session.get(AppUser, user_id)
|
||||
|
||||
|
||||
async def email_taken(session: AsyncSession, normalized_email: str) -> bool:
|
||||
"""Есть ли пользователь с таким email без учёта регистра (`normalized_email` — уже `strip().lower()`)."""
|
||||
stmt = select(AppUser.id).where(func.lower(AppUser.email) == normalized_email).limit(1)
|
||||
return (await session.execute(stmt)).first() is not None
|
||||
|
||||
|
||||
async def role_codes_by_user(session: AsyncSession, user_ids: Sequence[uuid.UUID]) -> dict[uuid.UUID, list[str]]:
|
||||
"""Коды НАЗНАЧЕННЫХ ролей (включая неактивные роли) по пользователям, по алфавиту."""
|
||||
if not user_ids:
|
||||
return {}
|
||||
stmt = (
|
||||
select(UserRole.user_id, Role.code)
|
||||
.join(Role, Role.id == UserRole.role_id)
|
||||
.where(UserRole.user_id.in_(user_ids))
|
||||
.order_by(Role.code)
|
||||
)
|
||||
result: dict[uuid.UUID, list[str]] = {user_id: [] for user_id in user_ids}
|
||||
for user_id, code in (await session.execute(stmt)).all():
|
||||
result[user_id].append(code)
|
||||
return result
|
||||
|
||||
|
||||
async def user_roles(session: AsyncSession, user_id: uuid.UUID) -> list[tuple[UserRole, Role]]:
|
||||
"""Назначения ролей пользователя вместе с ролями."""
|
||||
stmt = select(UserRole, Role).join(Role, Role.id == UserRole.role_id).where(UserRole.user_id == user_id)
|
||||
return [(user_role, role) for user_role, role in (await session.execute(stmt)).all()]
|
||||
|
||||
|
||||
async def delete_user_roles(session: AsyncSession, user_id: uuid.UUID, role_ids: Collection[uuid.UUID]) -> None:
|
||||
"""Снимает с пользователя назначения указанных ролей."""
|
||||
if role_ids:
|
||||
await session.execute(delete(UserRole).where(UserRole.user_id == user_id, UserRole.role_id.in_(role_ids)))
|
||||
|
||||
|
||||
async def roles_by_codes(session: AsyncSession, codes: Collection[str]) -> list[Role]:
|
||||
"""Роли с указанными кодами (любого статуса)."""
|
||||
if not codes:
|
||||
return []
|
||||
return list((await session.execute(select(Role).where(Role.code.in_(codes)))).scalars().all())
|
||||
|
||||
|
||||
async def get_role_by_code(session: AsyncSession, code: str) -> Role | None:
|
||||
"""Роль по коду."""
|
||||
return (await session.execute(select(Role).where(Role.code == code))).scalar_one_or_none()
|
||||
|
||||
|
||||
async def list_roles_with_permissions(session: AsyncSession) -> list[tuple[Role, list[str]]]:
|
||||
"""Все роли (по коду) с кодами их прав (по алфавиту)."""
|
||||
roles = list((await session.execute(select(Role).order_by(Role.code))).scalars().all())
|
||||
stmt = (
|
||||
select(RolePermission.role_id, Permission.code)
|
||||
.join(Permission, Permission.id == RolePermission.permission_id)
|
||||
.order_by(Permission.code)
|
||||
)
|
||||
codes: dict[uuid.UUID, list[str]] = {role.id: [] for role in roles}
|
||||
for role_id, code in (await session.execute(stmt)).all():
|
||||
codes.setdefault(role_id, []).append(code)
|
||||
return [(role, codes[role.id]) for role in roles]
|
||||
|
||||
|
||||
async def role_permissions(session: AsyncSession, role_id: uuid.UUID) -> list[Permission]:
|
||||
"""Права роли (по коду)."""
|
||||
stmt = (
|
||||
select(Permission)
|
||||
.join(RolePermission, RolePermission.permission_id == Permission.id)
|
||||
.where(RolePermission.role_id == role_id)
|
||||
.order_by(Permission.code)
|
||||
)
|
||||
return list((await session.execute(stmt)).scalars().all())
|
||||
|
||||
|
||||
async def delete_role_permissions(
|
||||
session: AsyncSession, role_id: uuid.UUID, permission_ids: Collection[uuid.UUID]
|
||||
) -> None:
|
||||
"""Снимает с роли указанные права."""
|
||||
if permission_ids:
|
||||
await session.execute(
|
||||
delete(RolePermission).where(
|
||||
RolePermission.role_id == role_id, RolePermission.permission_id.in_(permission_ids)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def list_permissions(session: AsyncSession) -> list[Permission]:
|
||||
"""Справочник прав (по коду)."""
|
||||
return list((await session.execute(select(Permission).order_by(Permission.code))).scalars().all())
|
||||
|
||||
|
||||
async def permissions_by_codes(session: AsyncSession, codes: Collection[str]) -> list[Permission]:
|
||||
"""Права с указанными кодами."""
|
||||
if not codes:
|
||||
return []
|
||||
return list((await session.execute(select(Permission).where(Permission.code.in_(codes)))).scalars().all())
|
||||
|
||||
|
||||
async def lock_permission(session: AsyncSession, code: str) -> None:
|
||||
"""`SELECT … FOR NO KEY UPDATE` строки права: сериализует проверки «последнего обладателя права» между транзакциями.
|
||||
|
||||
Пока транзакция держит блокировку, конкурентная проверка того же права ждёт её завершения и затем считает уже
|
||||
зафиксированное состояние (READ COMMITTED: каждый оператор видит данные, зафиксированные до его начала).
|
||||
`NO KEY UPDATE` не мешает вставкам `role_permission` со ссылкой на это право (FK берёт `FOR KEY SHARE`).
|
||||
"""
|
||||
await session.execute(select(Permission.id).where(Permission.code == code).with_for_update(key_share=True))
|
||||
|
||||
|
||||
async def roles_grant_permission(session: AsyncSession, role_ids: Collection[uuid.UUID], code: str) -> bool:
|
||||
"""Даёт ли хотя бы одна из АКТИВНЫХ ролей `role_ids` право `code`."""
|
||||
if not role_ids:
|
||||
return False
|
||||
stmt = (
|
||||
select(Role.id)
|
||||
.join(RolePermission, RolePermission.role_id == Role.id)
|
||||
.join(Permission, Permission.id == RolePermission.permission_id)
|
||||
.where(Role.id.in_(role_ids), Role.is_active.is_(True), Permission.code == code)
|
||||
.limit(1)
|
||||
)
|
||||
return (await session.execute(stmt)).first() is not None
|
||||
|
||||
|
||||
async def count_active_users_with_permission(
|
||||
session: AsyncSession,
|
||||
code: str,
|
||||
*,
|
||||
excluding_user: uuid.UUID | None = None,
|
||||
excluding_role: uuid.UUID | None = None,
|
||||
) -> int:
|
||||
"""Число АКТИВНЫХ пользователей, которым АКТИВНЫЕ роли дают право `code` (как `effective_permissions`).
|
||||
|
||||
`excluding_user` — не считать этого пользователя; `excluding_role` — не считать право, полученное через эту роль
|
||||
(пользователь учитывается, если право даёт другая его роль).
|
||||
"""
|
||||
stmt = (
|
||||
select(func.count(func.distinct(AppUser.id)))
|
||||
.join(UserRole, UserRole.user_id == AppUser.id)
|
||||
.join(Role, and_(Role.id == UserRole.role_id, Role.is_active.is_(True)))
|
||||
.join(RolePermission, RolePermission.role_id == Role.id)
|
||||
.join(Permission, Permission.id == RolePermission.permission_id)
|
||||
.where(AppUser.is_active.is_(True), Permission.code == code)
|
||||
)
|
||||
if excluding_user is not None:
|
||||
stmt = stmt.where(AppUser.id != excluding_user)
|
||||
if excluding_role is not None:
|
||||
stmt = stmt.where(Role.id != excluding_role)
|
||||
return (await session.execute(stmt)).scalar_one()
|
||||
|
||||
150
api/src/schemas/admin.py
Normal file
150
api/src/schemas/admin.py
Normal file
@ -0,0 +1,150 @@
|
||||
"""Схемы API администрирования `/admin/*` (spec users-admin, design D-8): пользователи, роли, матрица прав.
|
||||
|
||||
Поля — как в DBML v5.14 (`app_user`, `role`, `permission`); `external_id` через API не задаётся (появится при первом
|
||||
входе через РАИСА), поэтому тела запросов запрещают лишние поля.
|
||||
"""
|
||||
|
||||
import datetime
|
||||
import uuid
|
||||
from typing import Self
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, EmailStr, Field, field_validator, model_validator
|
||||
|
||||
from src.models.enums import BusinessSegment, PermissionAction, PermissionObject
|
||||
from src.schemas.directories import NAME_MAX_LENGTH, RegionalOfficeOut
|
||||
|
||||
# Поля `app_user`, которые не могут быть NULL: явный `null` в PATCH — ошибка валидации, а не «очистить»
|
||||
_NOT_NULL_UPDATE_FIELDS = ("full_name", "regional_office_id", "is_active")
|
||||
|
||||
|
||||
def _strip_full_name(value: str) -> str:
|
||||
stripped = value.strip()
|
||||
if not stripped:
|
||||
raise ValueError("ФИО не может быть пустым")
|
||||
return stripped
|
||||
|
||||
|
||||
def _blank_to_none(value: str | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
return value.strip() or None
|
||||
|
||||
|
||||
class UserCreate(BaseModel):
|
||||
"""Создание пользователя: email уникален без учёта регистра (проверяет сервис), роли — коды активных ролей."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
email: EmailStr
|
||||
full_name: str = Field(min_length=1, max_length=NAME_MAX_LENGTH)
|
||||
regional_office_id: uuid.UUID
|
||||
employee_no: str | None = None
|
||||
business_segment: BusinessSegment | None = None
|
||||
roles: list[str] = Field(default_factory=list)
|
||||
|
||||
@field_validator("full_name")
|
||||
@classmethod
|
||||
def _full_name(cls, value: str) -> str:
|
||||
return _strip_full_name(value)
|
||||
|
||||
@field_validator("employee_no")
|
||||
@classmethod
|
||||
def _employee_no(cls, value: str | None) -> str | None:
|
||||
return _blank_to_none(value)
|
||||
|
||||
|
||||
class UserUpdate(BaseModel):
|
||||
"""Изменение пользователя; «поле передано» — по `model_fields_set`.
|
||||
|
||||
`employee_no` и `business_segment` можно очистить (`null`); `full_name`, `regional_office_id`, `is_active` — нет.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
full_name: str | None = Field(default=None, min_length=1, max_length=NAME_MAX_LENGTH)
|
||||
employee_no: str | None = None
|
||||
regional_office_id: uuid.UUID | None = None
|
||||
business_segment: BusinessSegment | None = None
|
||||
is_active: bool | None = None
|
||||
|
||||
@field_validator("full_name")
|
||||
@classmethod
|
||||
def _full_name(cls, value: str | None) -> str | None:
|
||||
return None if value is None else _strip_full_name(value)
|
||||
|
||||
@field_validator("employee_no")
|
||||
@classmethod
|
||||
def _employee_no(cls, value: str | None) -> str | None:
|
||||
return _blank_to_none(value)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _no_null_for_required(self) -> Self:
|
||||
nulls = [
|
||||
name for name in _NOT_NULL_UPDATE_FIELDS if name in self.model_fields_set and getattr(self, name) is None
|
||||
]
|
||||
if nulls:
|
||||
raise ValueError(f"поля не могут быть null: {', '.join(nulls)}")
|
||||
return self
|
||||
|
||||
|
||||
class UserOut(BaseModel):
|
||||
"""Пользователь для администратора: филиал целиком и коды назначенных ролей (по алфавиту)."""
|
||||
|
||||
model_config = ConfigDict(use_enum_values=True)
|
||||
|
||||
id: uuid.UUID
|
||||
email: str | None
|
||||
full_name: str
|
||||
employee_no: str | None
|
||||
regional_office: RegionalOfficeOut
|
||||
business_segment: BusinessSegment | None
|
||||
is_active: bool
|
||||
roles: list[str]
|
||||
created_at: datetime.datetime | None
|
||||
updated_at: datetime.datetime | None
|
||||
|
||||
|
||||
class UserListQuery(BaseModel):
|
||||
"""Фильтры списка пользователей: `q` — подстрока email или ФИО без учёта регистра, `is_active`."""
|
||||
|
||||
q: str | None = None
|
||||
is_active: bool | None = None
|
||||
|
||||
|
||||
class RolesSet(BaseModel):
|
||||
"""Новый набор ролей пользователя (коды); заменяет прежний целиком."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
roles: list[str]
|
||||
|
||||
|
||||
class RoleOut(BaseModel):
|
||||
"""Роль с кодами её прав (по алфавиту)."""
|
||||
|
||||
code: str
|
||||
name: str
|
||||
description: str | None
|
||||
is_system: bool
|
||||
is_active: bool
|
||||
permissions: list[str]
|
||||
|
||||
|
||||
class PermissionOut(BaseModel):
|
||||
"""Право из справочника (только чтение: новые коды не создаются)."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True, use_enum_values=True)
|
||||
|
||||
code: str
|
||||
object_type: PermissionObject
|
||||
action: PermissionAction
|
||||
name: str
|
||||
description: str | None
|
||||
|
||||
|
||||
class PermissionsSet(BaseModel):
|
||||
"""Новый набор прав роли (коды); заменяет прежний целиком."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
permissions: list[str]
|
||||
302
api/src/services/users_admin.py
Normal file
302
api/src/services/users_admin.py
Normal file
@ -0,0 +1,302 @@
|
||||
"""Сервис администрирования (spec users-admin, audit-trail; design D-8; `05-domain-rules.md` §10).
|
||||
|
||||
Пользователи (создание, изменение, деактивация — без удаления), назначение ролей, матрица «роль × право». Каждая
|
||||
мутация пишет `audit_event` в транзакции запроса (`USER_CHANGE` / `USER_ROLE_CHANGE` — раздел `USERS`,
|
||||
`ROLE_PERMISSION_CHANGE` — `ACCESS`); повтор того же состояния ничего не пишет.
|
||||
|
||||
Защита `LAST_ADMIN`: после операции у активных пользователей должно остаться право `USERS_ADMIN`, полученное через
|
||||
активную роль (как в `effective_permissions`). Проверяется на деактивации пользователя, замене его ролей и снятии
|
||||
права с роли. Код роли не читается — только право.
|
||||
|
||||
Гонки: мутируемая строка (`app_user` / `role`) блокируется `FOR NO KEY UPDATE`, затем перед подсчётом — строка права
|
||||
`USERS_ADMIN` (`repo.lock_permission`): проверки «последнего администратора» выполняются по очереди, вторая видит
|
||||
зафиксированный результат первой. Порядок блокировок всегда «объект → право». Именно `NO KEY UPDATE`, а не
|
||||
`FOR UPDATE`: вставки `audit_event.user_id`, `user_role.role_id`, `role_permission.granted_by` берут на родительские
|
||||
строки `FOR KEY SHARE` (проверка FK), который `FOR UPDATE` блокирует — два администратора, правящие друг друга, ловили
|
||||
бы взаимную блокировку (deadlock) на вставке аудита.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from collections.abc import Iterable
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.api.dependencies.audit import RequestAudit
|
||||
from src.common.clock import utc_now
|
||||
from src.common.exceptions import ConflictError, DomainValidationError, NotFoundError
|
||||
from src.models.access import AppUser, Permission, Role, RolePermission, UserRole
|
||||
from src.models.directories import RegionalOffice
|
||||
from src.models.enums import AuditAction, AuditSection
|
||||
from src.repositories import directories as directories_repo
|
||||
from src.repositories import users as repo
|
||||
from src.schemas.admin import UserCreate, UserUpdate
|
||||
from src.schemas.common import PageParams
|
||||
|
||||
USERS_ADMIN = "USERS_ADMIN" # право, которое не должно пропасть у всех активных пользователей (D-8)
|
||||
USER_OBJECT = "app_user"
|
||||
ROLE_OBJECT = "role"
|
||||
USER_NOT_FOUND = "Пользователь не найден"
|
||||
ROLE_NOT_FOUND = "Роль не найдена"
|
||||
LAST_ADMIN_MESSAGE = "Нельзя оставить систему без активного пользователя с правом USERS_ADMIN"
|
||||
UPDATE_FIELDS = ("full_name", "employee_no", "regional_office_id", "business_segment", "is_active")
|
||||
|
||||
UserWithRoles = tuple[AppUser, RegionalOffice, list[str]]
|
||||
|
||||
|
||||
def _unique(codes: Iterable[str]) -> list[str]:
|
||||
"""Коды без дубликатов в порядке первого появления."""
|
||||
return list(dict.fromkeys(codes))
|
||||
|
||||
|
||||
class UsersAdminService:
|
||||
"""Операции администратора; права (`USERS_ADMIN`) проверяет маршрут, сервис — инварианты и аудит."""
|
||||
|
||||
def __init__(self, session: AsyncSession, audit: RequestAudit, actor_id: uuid.UUID) -> None:
|
||||
self._session = session
|
||||
self._audit = audit
|
||||
self._actor_id = actor_id
|
||||
|
||||
# --- пользователи ---------------------------------------------------------------------------------------------
|
||||
|
||||
async def list_users(
|
||||
self, q: str | None, is_active: bool | None, page: PageParams
|
||||
) -> tuple[list[UserWithRoles], int]:
|
||||
"""Страница пользователей с филиалом и кодами назначенных ролей; поиск `q` по email/ФИО."""
|
||||
rows, total = await repo.list_users(
|
||||
self._session, q=q, is_active=is_active, limit=page.limit, offset=page.offset
|
||||
)
|
||||
roles = await repo.role_codes_by_user(self._session, [user.id for user, _ in rows])
|
||||
return [(user, office, roles[user.id]) for user, office in rows], total
|
||||
|
||||
async def user_view(self, user_id: uuid.UUID) -> UserWithRoles:
|
||||
"""Пользователь с филиалом и ролями (для ответа); нет — 404."""
|
||||
user = await repo.get_user(self._session, user_id)
|
||||
if user is None:
|
||||
raise NotFoundError(USER_NOT_FOUND)
|
||||
office = await self._session.get(RegionalOffice, user.regional_office_id)
|
||||
if office is None: # FK NOT NULL: недостижимо при целостной БД
|
||||
raise NotFoundError("Филиал пользователя не найден")
|
||||
roles = await repo.role_codes_by_user(self._session, [user.id])
|
||||
return user, office, roles[user.id]
|
||||
|
||||
async def create_user(self, data: UserCreate) -> AppUser:
|
||||
"""Новый пользователь с ролями; email уникален без учёта регистра (409 `EMAIL_EXISTS`), хранится как введён."""
|
||||
email = str(data.email).strip()
|
||||
if await repo.email_taken(self._session, email.lower()):
|
||||
raise ConflictError("EMAIL_EXISTS", "Пользователь с таким email уже существует")
|
||||
await self._check_office(data.regional_office_id)
|
||||
codes = _unique(data.roles)
|
||||
roles = await self._active_roles(codes)
|
||||
now = utc_now()
|
||||
user = AppUser(
|
||||
email=email,
|
||||
full_name=data.full_name,
|
||||
employee_no=data.employee_no,
|
||||
regional_office_id=data.regional_office_id,
|
||||
business_segment=data.business_segment,
|
||||
is_active=True,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
self._session.add(user)
|
||||
await self._session.flush()
|
||||
self._session.add_all(UserRole(user_id=user.id, role_id=role.id) for role in roles)
|
||||
await self._session.flush()
|
||||
await self._audit.record(
|
||||
action=AuditAction.USER_CHANGE,
|
||||
section=AuditSection.USERS,
|
||||
object_type=USER_OBJECT,
|
||||
object_id=user.id,
|
||||
after_value={
|
||||
"email": user.email,
|
||||
"full_name": user.full_name,
|
||||
"employee_no": user.employee_no,
|
||||
"regional_office_id": user.regional_office_id,
|
||||
"business_segment": user.business_segment,
|
||||
"roles": codes,
|
||||
},
|
||||
)
|
||||
return user
|
||||
|
||||
async def update_user(self, user_id: uuid.UUID, data: UserUpdate) -> AppUser:
|
||||
"""Изменение полей и деактивация; без фактических изменений ничего не пишется.
|
||||
|
||||
Деактивация последнего активного обладателя `USERS_ADMIN` (в том числе самого себя) — 409 `LAST_ADMIN`.
|
||||
"""
|
||||
user = await self._lock_user(user_id)
|
||||
changes: dict[str, Any] = {}
|
||||
for field in UPDATE_FIELDS:
|
||||
if field in data.model_fields_set and getattr(data, field) != getattr(user, field):
|
||||
changes[field] = getattr(data, field)
|
||||
if not changes:
|
||||
return user
|
||||
if "regional_office_id" in changes:
|
||||
await self._check_office(changes["regional_office_id"])
|
||||
if changes.get("is_active") is False:
|
||||
await self._ensure_admin_remains(excluding_user=user.id)
|
||||
before = {field: getattr(user, field) for field in changes}
|
||||
for field, value in changes.items():
|
||||
setattr(user, field, value)
|
||||
user.updated_at = utc_now()
|
||||
await self._session.flush()
|
||||
await self._audit.record(
|
||||
action=AuditAction.USER_CHANGE,
|
||||
section=AuditSection.USERS,
|
||||
object_type=USER_OBJECT,
|
||||
object_id=user.id,
|
||||
before_value=before,
|
||||
after_value=changes,
|
||||
changed_field=next(iter(changes)) if len(changes) == 1 else None,
|
||||
)
|
||||
return user
|
||||
|
||||
async def set_roles(self, user_id: uuid.UUID, codes: list[str]) -> list[str]:
|
||||
"""Заменяет набор ролей пользователя; возвращает новые коды по алфавиту.
|
||||
|
||||
Неизвестный или неактивный код — 422 `UNKNOWN_ROLE`; если после замены ни у одного активного пользователя не
|
||||
останется `USERS_ADMIN` — 409 `LAST_ADMIN`. Аудит `USER_ROLE_CHANGE`: before — прежние коды, after — новые
|
||||
в порядке запроса.
|
||||
"""
|
||||
user = await self._lock_user(user_id)
|
||||
new_codes = _unique(codes)
|
||||
new_roles = await self._active_roles(new_codes)
|
||||
current = await repo.user_roles(self._session, user.id)
|
||||
old_ids = {role.id for _, role in current}
|
||||
new_ids = {role.id for role in new_roles}
|
||||
if old_ids == new_ids:
|
||||
return sorted(new_codes)
|
||||
removed = old_ids - new_ids
|
||||
if removed:
|
||||
await self._ensure_admin_remains(excluding_user=user.id, kept_roles=new_ids if user.is_active else None)
|
||||
await repo.delete_user_roles(self._session, user.id, removed)
|
||||
self._session.add_all(UserRole(user_id=user.id, role_id=role_id) for role_id in new_ids - old_ids)
|
||||
await self._session.flush()
|
||||
await self._audit.record(
|
||||
action=AuditAction.USER_ROLE_CHANGE,
|
||||
section=AuditSection.USERS,
|
||||
object_type=USER_OBJECT,
|
||||
object_id=user.id,
|
||||
before_value={"roles": sorted(role.code for _, role in current)},
|
||||
after_value={"roles": new_codes},
|
||||
changed_field="roles",
|
||||
)
|
||||
return sorted(new_codes)
|
||||
|
||||
# --- роли и права ---------------------------------------------------------------------------------------------
|
||||
|
||||
async def list_roles(self) -> list[tuple[Role, list[str]]]:
|
||||
"""Все роли (по коду) с кодами прав."""
|
||||
return await repo.list_roles_with_permissions(self._session)
|
||||
|
||||
async def list_permissions(self) -> list[Permission]:
|
||||
"""Справочник прав (по коду)."""
|
||||
return await repo.list_permissions(self._session)
|
||||
|
||||
async def role_view(self, code: str) -> tuple[Role, list[str]]:
|
||||
"""Роль с кодами прав; нет — 404."""
|
||||
role = await repo.get_role_by_code(self._session, code)
|
||||
if role is None:
|
||||
raise NotFoundError(ROLE_NOT_FOUND)
|
||||
return role, [permission.code for permission in await repo.role_permissions(self._session, role.id)]
|
||||
|
||||
async def set_role_permissions(self, code: str, permission_codes: list[str]) -> list[str]:
|
||||
"""Заменяет набор прав роли; возвращает новые коды по алфавиту.
|
||||
|
||||
Неизвестная роль — 404; неизвестный код права — 422 `UNKNOWN_PERMISSION` (новые права не создаются); снятие
|
||||
`USERS_ADMIN`, после которого его не останется ни у одного активного пользователя, — 409 `LAST_ADMIN`.
|
||||
"""
|
||||
stmt = (
|
||||
select(Role)
|
||||
.where(Role.code == code)
|
||||
.with_for_update(key_share=True)
|
||||
.execution_options(populate_existing=True)
|
||||
)
|
||||
role = (await self._session.execute(stmt)).scalar_one_or_none()
|
||||
if role is None:
|
||||
raise NotFoundError(ROLE_NOT_FOUND)
|
||||
new_codes = _unique(permission_codes)
|
||||
found = {
|
||||
permission.code: permission for permission in await repo.permissions_by_codes(self._session, new_codes)
|
||||
}
|
||||
unknown = [item for item in new_codes if item not in found]
|
||||
if unknown:
|
||||
raise DomainValidationError(
|
||||
"UNKNOWN_PERMISSION", f"Неизвестные коды прав: {', '.join(unknown)}", permissions=unknown
|
||||
)
|
||||
current = {permission.code: permission for permission in await repo.role_permissions(self._session, role.id)}
|
||||
if set(current) == set(found):
|
||||
return sorted(found)
|
||||
removed = set(current) - set(found)
|
||||
if USERS_ADMIN in removed:
|
||||
await self._ensure_admin_remains(excluding_role=role.id)
|
||||
await repo.delete_role_permissions(self._session, role.id, {current[item].id for item in removed})
|
||||
now = utc_now()
|
||||
self._session.add_all(
|
||||
RolePermission(role_id=role.id, permission_id=found[item].id, granted_by=self._actor_id, granted_at=now)
|
||||
for item in set(found) - set(current)
|
||||
)
|
||||
await self._session.flush()
|
||||
await self._audit.record(
|
||||
action=AuditAction.ROLE_PERMISSION_CHANGE,
|
||||
section=AuditSection.ACCESS,
|
||||
object_type=ROLE_OBJECT,
|
||||
object_id=role.id,
|
||||
before_value={"permissions": sorted(current)},
|
||||
after_value={"permissions": sorted(found)},
|
||||
changed_field="permissions",
|
||||
)
|
||||
return sorted(found)
|
||||
|
||||
# --- проверки -------------------------------------------------------------------------------------------------
|
||||
|
||||
async def _lock_user(self, user_id: uuid.UUID) -> AppUser:
|
||||
"""Пользователь под `FOR NO KEY UPDATE` (конкурентные правки одного пользователя идут по очереди); нет — 404."""
|
||||
stmt = (
|
||||
select(AppUser)
|
||||
.where(AppUser.id == user_id)
|
||||
.with_for_update(key_share=True)
|
||||
.execution_options(populate_existing=True)
|
||||
)
|
||||
user = (await self._session.execute(stmt)).scalar_one_or_none()
|
||||
if user is None:
|
||||
raise NotFoundError(USER_NOT_FOUND)
|
||||
return user
|
||||
|
||||
async def _active_roles(self, codes: list[str]) -> list[Role]:
|
||||
"""Активные роли по кодам; неизвестные и неактивные — 422 `UNKNOWN_ROLE` со списком кодов."""
|
||||
roles = {role.code: role for role in await repo.roles_by_codes(self._session, codes) if role.is_active}
|
||||
unknown = [code for code in codes if code not in roles]
|
||||
if unknown:
|
||||
raise DomainValidationError(
|
||||
"UNKNOWN_ROLE", f"Неизвестные или неактивные роли: {', '.join(unknown)}", roles=unknown
|
||||
)
|
||||
return [roles[code] for code in codes]
|
||||
|
||||
async def _check_office(self, office_id: uuid.UUID) -> None:
|
||||
"""Филиал из тела запроса должен существовать — 422 `OFFICE_NOT_FOUND` (как у ГК/ЮЛ, R-8)."""
|
||||
if await directories_repo.get_regional_office(self._session, office_id) is None:
|
||||
raise DomainValidationError("OFFICE_NOT_FOUND", "Филиал не найден")
|
||||
|
||||
async def _ensure_admin_remains(
|
||||
self,
|
||||
*,
|
||||
excluding_user: uuid.UUID | None = None,
|
||||
excluding_role: uuid.UUID | None = None,
|
||||
kept_roles: set[uuid.UUID] | None = None,
|
||||
) -> None:
|
||||
"""409 `LAST_ADMIN`, если после операции ни у одного активного пользователя не останется `USERS_ADMIN`.
|
||||
|
||||
Сначала блокируется строка права (см. докстринг модуля), затем — уже по зафиксированному состоянию — считаются
|
||||
ДРУГИЕ обладатели права: без `excluding_user` или без права через `excluding_role`. `kept_roles` — роли, которые
|
||||
останутся у активного `excluding_user`: если одна из них даёт `USERS_ADMIN`, право у него сохраняется.
|
||||
"""
|
||||
await repo.lock_permission(self._session, USERS_ADMIN)
|
||||
if kept_roles is not None and await repo.roles_grant_permission(self._session, kept_roles, USERS_ADMIN):
|
||||
return
|
||||
others = await repo.count_active_users_with_permission(
|
||||
self._session, USERS_ADMIN, excluding_user=excluding_user, excluding_role=excluding_role
|
||||
)
|
||||
if others == 0:
|
||||
raise ConflictError("LAST_ADMIN", LAST_ADMIN_MESSAGE)
|
||||
575
api/tests/test_api_admin.py
Normal file
575
api/tests/test_api_admin.py
Normal file
@ -0,0 +1,575 @@
|
||||
"""API администрирования `/admin/*` (spec users-admin, audit-trail; design D-8; Review Focus 5 фазы 2).
|
||||
|
||||
Пользователи (создание, изменение, деактивация), назначение ролей, матрица «роль × право», аудит и защита `LAST_ADMIN`:
|
||||
последнее право `USERS_ADMIN` у активных пользователей нельзя снять ни деактивацией, ни заменой ролей, ни снятием
|
||||
права с роли. Новые пользователи — с доменом `example.com`: `EmailStr` не принимает зарезервированный `.local`
|
||||
dev-пользователей.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import uuid
|
||||
from collections.abc import Sequence
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
from src.api.dependencies.audit import RequestAudit
|
||||
from src.common.exceptions import DomainError
|
||||
from src.models.access import AppUser, Permission, Role, RolePermission, UserRole
|
||||
from src.models.audit_ex import AuditEvent
|
||||
from src.models.directories import RegionalOffice
|
||||
from src.models.enums import AuditAction, AuditSection
|
||||
from src.repositories import users as users_repo
|
||||
from src.services.audit import AuditService
|
||||
from src.services.users_admin import UsersAdminService
|
||||
from tests.conftest import AuthClientFactory
|
||||
from tests.factories import make_user
|
||||
|
||||
HTTP_OK = 200
|
||||
HTTP_CREATED = 201
|
||||
HTTP_FORBIDDEN = 403
|
||||
HTTP_NOT_FOUND = 404
|
||||
HTTP_CONFLICT = 409
|
||||
HTTP_UNPROCESSABLE = 422
|
||||
|
||||
ADMIN = "admin@armka.local"
|
||||
USERS = "/admin/users"
|
||||
ROSTOV = "Ростовский региональный филиал"
|
||||
HEAD_OFFICE = "Головной офис"
|
||||
PERMISSIONS_TOTAL = 16 # seed: 16 кодов прав (§10)
|
||||
EXPERT_PERMISSIONS = ["CONSOLIDATION_VIEW", "DIRECTORY_VIEW", "DOCUMENT_VIEW", "EX_VIEW", "KFO_VIEW"]
|
||||
|
||||
|
||||
async def _office(session: AsyncSession, name: str) -> RegionalOffice:
|
||||
return (await session.execute(select(RegionalOffice).where(RegionalOffice.name == name))).scalar_one()
|
||||
|
||||
|
||||
async def _user_id(session: AsyncSession, email: str) -> uuid.UUID:
|
||||
return (await session.execute(select(AppUser.id).where(AppUser.email == email))).scalar_one()
|
||||
|
||||
|
||||
async def _role_codes(session: AsyncSession, user_id: uuid.UUID) -> list[str]:
|
||||
stmt = select(Role.code).join(UserRole, UserRole.role_id == Role.id).where(UserRole.user_id == user_id)
|
||||
return sorted((await session.execute(stmt)).scalars().all())
|
||||
|
||||
|
||||
async def _events(session: AsyncSession, action: AuditAction) -> Sequence[AuditEvent]:
|
||||
stmt = select(AuditEvent).where(AuditEvent.action == action).order_by(AuditEvent.occurred_at, AuditEvent.id)
|
||||
return (await session.execute(stmt)).scalars().all()
|
||||
|
||||
|
||||
async def _create(client: httpx.AsyncClient, **body: Any) -> dict[str, Any]:
|
||||
response = await client.post(USERS, json=body)
|
||||
assert response.status_code == HTTP_CREATED, response.text
|
||||
created: dict[str, Any] = response.json()
|
||||
return created
|
||||
|
||||
|
||||
def _detail_code(response: httpx.Response) -> str:
|
||||
code: str = response.json()["detail"]["code"]
|
||||
return code
|
||||
|
||||
|
||||
# --- Пользователи ------------------------------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_create_user_with_role_gives_role_permissions_in_me(
|
||||
auth_client: AuthClientFactory, seeded: AsyncSession
|
||||
) -> None:
|
||||
admin = await auth_client(ADMIN)
|
||||
rostov = await _office(seeded, ROSTOV)
|
||||
created = await _create(
|
||||
admin,
|
||||
email=" Ivan.Petrov@Example.com ",
|
||||
full_name="Иван Петров",
|
||||
regional_office_id=str(rostov.id),
|
||||
employee_no="E-1",
|
||||
business_segment="DKB",
|
||||
roles=["KA_RF"],
|
||||
)
|
||||
assert created["email"] == "Ivan.Petrov@example.com" # регистр локальной части сохранён (EmailStr: домен строчными)
|
||||
assert created["roles"] == ["KA_RF"]
|
||||
assert created["is_active"] is True
|
||||
assert created["regional_office"] == {
|
||||
"id": str(rostov.id),
|
||||
"name": ROSTOV,
|
||||
"is_head_office": False,
|
||||
"is_active": True,
|
||||
}
|
||||
assert set(created) == {
|
||||
"id",
|
||||
"email",
|
||||
"full_name",
|
||||
"employee_no",
|
||||
"regional_office",
|
||||
"business_segment",
|
||||
"is_active",
|
||||
"roles",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
}
|
||||
user_id = uuid.UUID(created["id"])
|
||||
assert await _role_codes(seeded, user_id) == ["KA_RF"]
|
||||
user = await seeded.get(AppUser, user_id)
|
||||
assert user is not None and user.external_id is None and user.created_at is not None
|
||||
|
||||
events = await _events(seeded, AuditAction.USER_CHANGE)
|
||||
assert len(events) == 1
|
||||
event = events[0]
|
||||
assert (event.section, event.object_type, event.object_id) == (AuditSection.USERS, "app_user", user_id)
|
||||
assert event.user_id == await _user_id(seeded, ADMIN)
|
||||
assert event.after_value == {
|
||||
"email": "Ivan.Petrov@example.com",
|
||||
"full_name": "Иван Петров",
|
||||
"employee_no": "E-1",
|
||||
"regional_office_id": str(rostov.id),
|
||||
"business_segment": "DKB",
|
||||
"roles": ["KA_RF"],
|
||||
}
|
||||
|
||||
reference = (await (await auth_client("ka_rf@armka.local")).get("/me")).json()
|
||||
me = await (await auth_client("ivan.petrov@example.com")).get("/me")
|
||||
assert me.status_code == HTTP_OK, me.text
|
||||
assert me.json()["roles"] == ["KA_RF"]
|
||||
assert me.json()["permissions"] == reference["permissions"]
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_create_user_email_in_other_case_conflicts(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
|
||||
admin = await auth_client(ADMIN)
|
||||
office_id = str((await _office(seeded, HEAD_OFFICE)).id)
|
||||
await _create(admin, email="anna@example.com", full_name="Анна", regional_office_id=office_id)
|
||||
response = await admin.post(
|
||||
USERS, json={"email": "ANNA@Example.COM", "full_name": "Анна 2", "regional_office_id": office_id}
|
||||
)
|
||||
assert response.status_code == HTTP_CONFLICT
|
||||
assert _detail_code(response) == "EMAIL_EXISTS"
|
||||
total = (await seeded.execute(select(func.count()).where(func.lower(AppUser.email) == "anna@example.com"))).scalar()
|
||||
assert total == 1
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_create_user_validation(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
|
||||
admin = await auth_client(ADMIN)
|
||||
office_id = str((await _office(seeded, HEAD_OFFICE)).id)
|
||||
base = {"email": "v@example.com", "full_name": "В", "regional_office_id": office_id}
|
||||
|
||||
unknown_role = await admin.post(USERS, json={**base, "roles": ["KA_RF", "NO_SUCH_ROLE"]})
|
||||
assert unknown_role.status_code == HTTP_UNPROCESSABLE
|
||||
assert unknown_role.json()["detail"]["code"] == "UNKNOWN_ROLE"
|
||||
assert unknown_role.json()["detail"]["roles"] == ["NO_SUCH_ROLE"]
|
||||
|
||||
unknown_office = await admin.post(USERS, json={**base, "regional_office_id": str(uuid.uuid4())})
|
||||
assert unknown_office.status_code == HTTP_UNPROCESSABLE
|
||||
assert _detail_code(unknown_office) == "OFFICE_NOT_FOUND"
|
||||
|
||||
for body in (
|
||||
{**base, "external_id": "raisa-1"}, # external_id через API не задаётся
|
||||
{**base, "email": "not-an-email"},
|
||||
{**base, "full_name": " "},
|
||||
):
|
||||
response = await admin.post(USERS, json=body)
|
||||
assert response.status_code == HTTP_UNPROCESSABLE, body
|
||||
assert (await seeded.execute(select(AppUser).where(AppUser.email == "v@example.com"))).first() is None
|
||||
assert await _events(seeded, AuditAction.USER_CHANGE) == []
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_list_users_search_filter_and_page(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
|
||||
office = await _office(seeded, ROSTOV)
|
||||
await make_user(seeded, email="Zoya_Search@example.com", office=office, roles=["KA_RF", "EXPERT"], full_name="Зоя")
|
||||
await make_user(seeded, email="off@example.com", office=office, full_name="Зоя Отключённая", is_active=False)
|
||||
admin = await auth_client(ADMIN)
|
||||
|
||||
everyone = (await admin.get(USERS)).json()
|
||||
assert set(everyone) == {"items", "total", "limit", "offset"}
|
||||
assert everyone["total"] == len(everyone["items"]) >= 9 # noqa: PLR2004 - 7 dev-пользователей + 2 новых
|
||||
|
||||
by_email = (await admin.get(USERS, params={"q": "zoya_s"})).json() # `_` ищется буквально, без регистра
|
||||
assert [item["email"] for item in by_email["items"]] == ["Zoya_Search@example.com"]
|
||||
assert by_email["items"][0]["roles"] == ["EXPERT", "KA_RF"]
|
||||
assert by_email["items"][0]["regional_office"]["name"] == ROSTOV
|
||||
|
||||
by_name = (await admin.get(USERS, params={"q": "зоя"})).json()
|
||||
assert by_name["total"] == 2 # noqa: PLR2004
|
||||
inactive = (await admin.get(USERS, params={"q": "зоя", "is_active": "false"})).json()
|
||||
assert [item["email"] for item in inactive["items"]] == ["off@example.com"]
|
||||
assert inactive["items"][0]["roles"] == []
|
||||
|
||||
page = (await admin.get(USERS, params={"limit": 2, "offset": 1})).json()
|
||||
assert (len(page["items"]), page["limit"], page["offset"], page["total"]) == (2, 2, 1, everyone["total"])
|
||||
assert page["items"] == everyone["items"][1:3]
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_patch_user_fields_audit_and_noop(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
|
||||
rostov = await _office(seeded, ROSTOV)
|
||||
head = await _office(seeded, HEAD_OFFICE)
|
||||
user = await make_user(seeded, email="p@example.com", office=rostov, roles=["KA_RF"], full_name="Пётр")
|
||||
admin = await auth_client(ADMIN)
|
||||
|
||||
response = await admin.patch(
|
||||
f"{USERS}/{user.id}", json={"full_name": "Пётр Петров", "regional_office_id": str(head.id)}
|
||||
)
|
||||
assert response.status_code == HTTP_OK, response.text
|
||||
body = response.json()
|
||||
assert body["full_name"] == "Пётр Петров"
|
||||
assert body["regional_office"]["id"] == str(head.id)
|
||||
assert body["roles"] == ["KA_RF"]
|
||||
events = await _events(seeded, AuditAction.USER_CHANGE)
|
||||
assert len(events) == 1
|
||||
assert (events[0].section, events[0].object_type, events[0].object_id) == (AuditSection.USERS, "app_user", user.id)
|
||||
assert events[0].before_value == {"full_name": "Пётр", "regional_office_id": str(rostov.id)}
|
||||
assert events[0].after_value == {"full_name": "Пётр Петров", "regional_office_id": str(head.id)}
|
||||
assert events[0].changed_field is None
|
||||
|
||||
single = await admin.patch(f"{USERS}/{user.id}", json={"employee_no": "E-7"})
|
||||
assert single.status_code == HTTP_OK
|
||||
last = (await _events(seeded, AuditAction.USER_CHANGE))[-1]
|
||||
assert (last.changed_field, last.before_value, last.after_value) == (
|
||||
"employee_no",
|
||||
{"employee_no": None},
|
||||
{"employee_no": "E-7"},
|
||||
)
|
||||
|
||||
noop = await admin.patch(f"{USERS}/{user.id}", json={"full_name": "Пётр Петров", "employee_no": "E-7"})
|
||||
assert noop.status_code == HTTP_OK
|
||||
assert len(await _events(seeded, AuditAction.USER_CHANGE)) == 2 # noqa: PLR2004 - no-op не пишет аудит
|
||||
|
||||
for bad in ({"email": "x@example.com"}, {"full_name": None}, {"is_active": None}, {"regional_office_id": None}):
|
||||
assert (await admin.patch(f"{USERS}/{user.id}", json=bad)).status_code == HTTP_UNPROCESSABLE, bad
|
||||
office_missing = await admin.patch(f"{USERS}/{user.id}", json={"regional_office_id": str(uuid.uuid4())})
|
||||
assert office_missing.status_code == HTTP_UNPROCESSABLE
|
||||
assert _detail_code(office_missing) == "OFFICE_NOT_FOUND"
|
||||
assert (await admin.patch(f"{USERS}/{uuid.uuid4()}", json={"full_name": "Х"})).status_code == HTTP_NOT_FOUND
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_deactivated_user_loses_access(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
|
||||
user_id = await _user_id(seeded, "expert@armka.local")
|
||||
expert = await auth_client("expert@armka.local")
|
||||
admin = await auth_client(ADMIN)
|
||||
response = await admin.patch(f"{USERS}/{user_id}", json={"is_active": False})
|
||||
assert response.status_code == HTTP_OK
|
||||
assert response.json()["is_active"] is False
|
||||
assert (await expert.get("/me")).status_code == HTTP_FORBIDDEN
|
||||
event = (await _events(seeded, AuditAction.USER_CHANGE))[-1]
|
||||
assert (event.changed_field, event.before_value, event.after_value) == (
|
||||
"is_active",
|
||||
{"is_active": True},
|
||||
{"is_active": False},
|
||||
)
|
||||
|
||||
|
||||
# --- Назначение ролей --------------------------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_put_roles_replaces_set_with_audit(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
|
||||
user = await make_user(seeded, email="r@example.com", office=await _office(seeded, ROSTOV), roles=["KA_RF"])
|
||||
admin = await auth_client(ADMIN)
|
||||
|
||||
response = await admin.put(f"{USERS}/{user.id}/roles", json={"roles": ["KA_GO", "EXPERT", "KA_GO"]})
|
||||
assert response.status_code == HTTP_OK, response.text
|
||||
assert response.json()["roles"] == ["EXPERT", "KA_GO"]
|
||||
assert await _role_codes(seeded, user.id) == ["EXPERT", "KA_GO"]
|
||||
rows = (await seeded.execute(select(func.count()).where(UserRole.user_id == user.id))).scalar()
|
||||
assert rows == 2 # noqa: PLR2004
|
||||
|
||||
events = await _events(seeded, AuditAction.USER_ROLE_CHANGE)
|
||||
assert len(events) == 1
|
||||
event = events[0]
|
||||
assert (event.section, event.object_type, event.object_id) == (AuditSection.USERS, "app_user", user.id)
|
||||
assert event.before_value == {"roles": ["KA_RF"]}
|
||||
assert event.after_value == {"roles": ["KA_GO", "EXPERT"]}
|
||||
|
||||
same = await admin.put(f"{USERS}/{user.id}/roles", json={"roles": ["EXPERT", "KA_GO"]})
|
||||
assert same.status_code == HTTP_OK
|
||||
assert len(await _events(seeded, AuditAction.USER_ROLE_CHANGE)) == 1 # тот же набор — без аудита
|
||||
|
||||
cleared = await admin.put(f"{USERS}/{user.id}/roles", json={"roles": []})
|
||||
assert cleared.status_code == HTTP_OK
|
||||
assert await _role_codes(seeded, user.id) == []
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_put_roles_rejects_unknown_and_inactive_role(
|
||||
auth_client: AuthClientFactory, seeded: AsyncSession
|
||||
) -> None:
|
||||
user = await make_user(seeded, email="u@example.com", office=await _office(seeded, ROSTOV), roles=["KA_RF"])
|
||||
seeded.add(Role(code="RETIRED", name="Упразднённая", is_system=False, is_active=False))
|
||||
await seeded.flush()
|
||||
admin = await auth_client(ADMIN)
|
||||
|
||||
for codes, bad in ((["KA_GO", "NOPE"], ["NOPE"]), (["RETIRED"], ["RETIRED"])):
|
||||
response = await admin.put(f"{USERS}/{user.id}/roles", json={"roles": codes})
|
||||
assert response.status_code == HTTP_UNPROCESSABLE
|
||||
assert response.json()["detail"]["code"] == "UNKNOWN_ROLE"
|
||||
assert response.json()["detail"]["roles"] == bad
|
||||
assert await _role_codes(seeded, user.id) == ["KA_RF"]
|
||||
missing = await admin.put(f"{USERS}/{uuid.uuid4()}/roles", json={"roles": ["KA_GO"]})
|
||||
assert missing.status_code == HTTP_NOT_FOUND
|
||||
|
||||
|
||||
# --- LAST_ADMIN --------------------------------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_last_admin_cannot_drop_own_admin_role(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
|
||||
admin_id = await _user_id(seeded, ADMIN)
|
||||
admin = await auth_client(ADMIN)
|
||||
response = await admin.put(f"{USERS}/{admin_id}/roles", json={"roles": ["EXPERT"]})
|
||||
assert response.status_code == HTTP_CONFLICT
|
||||
assert _detail_code(response) == "LAST_ADMIN"
|
||||
assert await _role_codes(seeded, admin_id) == ["ADMIN"]
|
||||
assert await _events(seeded, AuditAction.USER_ROLE_CHANGE) == []
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_inactive_user_or_inactive_role_is_not_an_admin_source(
|
||||
auth_client: AuthClientFactory, seeded: AsyncSession
|
||||
) -> None:
|
||||
head = await _office(seeded, HEAD_OFFICE)
|
||||
await make_user(seeded, email="sleeping.admin@example.com", office=head, roles=["ADMIN"], is_active=False)
|
||||
retired = Role(code="RETIRED_ADMIN", name="Бывшая админская", is_system=False, is_active=False)
|
||||
seeded.add(retired)
|
||||
await seeded.flush()
|
||||
users_admin = (await seeded.execute(select(Permission.id).where(Permission.code == "USERS_ADMIN"))).scalar_one()
|
||||
seeded.add(RolePermission(role_id=retired.id, permission_id=users_admin))
|
||||
await make_user(seeded, email="retired.admin@example.com", office=head, roles=["RETIRED_ADMIN"])
|
||||
|
||||
admin_id = await _user_id(seeded, ADMIN)
|
||||
admin = await auth_client(ADMIN)
|
||||
response = await admin.put(f"{USERS}/{admin_id}/roles", json={"roles": ["EXPERT"]})
|
||||
assert response.status_code == HTTP_CONFLICT
|
||||
assert _detail_code(response) == "LAST_ADMIN"
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_second_admin_allows_dropping_own_admin_role(
|
||||
auth_client: AuthClientFactory, seeded: AsyncSession
|
||||
) -> None:
|
||||
await make_user(
|
||||
seeded, email="second.admin@example.com", office=await _office(seeded, HEAD_OFFICE), roles=["ADMIN"]
|
||||
)
|
||||
admin_id = await _user_id(seeded, ADMIN)
|
||||
admin = await auth_client(ADMIN)
|
||||
response = await admin.put(f"{USERS}/{admin_id}/roles", json={"roles": ["EXPERT"]})
|
||||
assert response.status_code == HTTP_OK, response.text
|
||||
assert await _role_codes(seeded, admin_id) == ["EXPERT"]
|
||||
assert (await admin.get("/admin/roles")).status_code == HTTP_FORBIDDEN # права пересчитаны на следующем запросе
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_last_admin_cannot_deactivate_self(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
|
||||
"""Review Focus 5: последний активный с `USERS_ADMIN` не может деактивировать себя; при втором — может."""
|
||||
admin_id = await _user_id(seeded, ADMIN)
|
||||
admin = await auth_client(ADMIN)
|
||||
response = await admin.patch(f"{USERS}/{admin_id}", json={"is_active": False, "full_name": "Не применится"})
|
||||
assert response.status_code == HTTP_CONFLICT
|
||||
assert _detail_code(response) == "LAST_ADMIN"
|
||||
user = await seeded.get(AppUser, admin_id)
|
||||
assert user is not None
|
||||
await seeded.refresh(user)
|
||||
assert user.is_active is True and user.full_name != "Не применится"
|
||||
assert await _events(seeded, AuditAction.USER_CHANGE) == []
|
||||
|
||||
await make_user(
|
||||
seeded, email="second.admin@example.com", office=await _office(seeded, HEAD_OFFICE), roles=["ADMIN"]
|
||||
)
|
||||
allowed = await admin.patch(f"{USERS}/{admin_id}", json={"is_active": False})
|
||||
assert allowed.status_code == HTTP_OK, allowed.text
|
||||
assert allowed.json()["is_active"] is False
|
||||
assert (await admin.get(USERS)).status_code == HTTP_FORBIDDEN # отключённый пользователь — 403
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_deactivating_other_admin_respects_last_admin(
|
||||
auth_client: AuthClientFactory, seeded: AsyncSession
|
||||
) -> None:
|
||||
second = await make_user(
|
||||
seeded, email="second.admin@example.com", office=await _office(seeded, HEAD_OFFICE), roles=["ADMIN"]
|
||||
)
|
||||
admin = await auth_client(ADMIN)
|
||||
second_client = await auth_client("second.admin@example.com")
|
||||
assert (await admin.patch(f"{USERS}/{second.id}", json={"is_active": False})).status_code == HTTP_OK
|
||||
# второй отключён — первый снова последний
|
||||
admin_id = await _user_id(seeded, ADMIN)
|
||||
response = await admin.patch(f"{USERS}/{admin_id}", json={"is_active": False})
|
||||
assert response.status_code == HTTP_CONFLICT
|
||||
assert (await second_client.get(USERS)).status_code == HTTP_FORBIDDEN
|
||||
|
||||
|
||||
# --- Матрица прав ------------------------------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_roles_and_permissions_listing(auth_client: AuthClientFactory) -> None:
|
||||
admin = await auth_client(ADMIN)
|
||||
permissions = (await admin.get("/admin/permissions")).json()
|
||||
assert len(permissions) == PERMISSIONS_TOTAL
|
||||
codes = [item["code"] for item in permissions]
|
||||
assert codes == sorted(codes) and "USERS_ADMIN" in codes
|
||||
users_admin = next(item for item in permissions if item["code"] == "USERS_ADMIN")
|
||||
assert set(users_admin) == {"code", "object_type", "action", "name", "description"}
|
||||
assert (users_admin["object_type"], users_admin["action"]) == ("USERS", "ADMIN")
|
||||
|
||||
roles = {item["code"]: item for item in (await admin.get("/admin/roles")).json()}
|
||||
assert {"ADMIN", "EXPERT", "KA_RF", "KA_GO"} <= set(roles)
|
||||
assert roles["EXPERT"]["permissions"] == EXPERT_PERMISSIONS
|
||||
assert "USERS_ADMIN" in roles["ADMIN"]["permissions"]
|
||||
assert set(roles["ADMIN"]) == {"code", "name", "description", "is_system", "is_active", "permissions"}
|
||||
assert roles["ADMIN"]["is_system"] is True
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_role_permissions_change_reaches_me_and_audit(
|
||||
auth_client: AuthClientFactory, seeded: AsyncSession
|
||||
) -> None:
|
||||
admin = await auth_client(ADMIN)
|
||||
expert = await auth_client("expert@armka.local")
|
||||
assert "EXPORT" not in (await expert.get("/me")).json()["permissions"]
|
||||
|
||||
response = await admin.put("/admin/roles/EXPERT/permissions", json={"permissions": [*EXPERT_PERMISSIONS, "EXPORT"]})
|
||||
assert response.status_code == HTTP_OK, response.text
|
||||
assert response.json()["permissions"] == sorted([*EXPERT_PERMISSIONS, "EXPORT"])
|
||||
|
||||
role = (await seeded.execute(select(Role).where(Role.code == "EXPERT"))).scalar_one()
|
||||
row = (
|
||||
await seeded.execute(
|
||||
select(RolePermission)
|
||||
.join(Permission, Permission.id == RolePermission.permission_id)
|
||||
.where(RolePermission.role_id == role.id, Permission.code == "EXPORT")
|
||||
)
|
||||
).scalar_one()
|
||||
admin_id = await _user_id(seeded, ADMIN)
|
||||
assert row.granted_by == admin_id and row.granted_at is not None
|
||||
assert "EXPORT" in (await expert.get("/me")).json()["permissions"]
|
||||
|
||||
events = await _events(seeded, AuditAction.ROLE_PERMISSION_CHANGE)
|
||||
assert len(events) == 1
|
||||
event = events[0]
|
||||
assert (event.section, event.object_type, event.object_id, event.user_id) == (
|
||||
AuditSection.ACCESS,
|
||||
"role",
|
||||
role.id,
|
||||
admin_id,
|
||||
)
|
||||
assert event.before_value == {"permissions": EXPERT_PERMISSIONS}
|
||||
assert event.after_value == {"permissions": sorted([*EXPERT_PERMISSIONS, "EXPORT"])}
|
||||
|
||||
same = await admin.put("/admin/roles/EXPERT/permissions", json={"permissions": [*EXPERT_PERMISSIONS, "EXPORT"]})
|
||||
assert same.status_code == HTTP_OK
|
||||
assert len(await _events(seeded, AuditAction.ROLE_PERMISSION_CHANGE)) == 1
|
||||
|
||||
shrink = await admin.put("/admin/roles/EXPERT/permissions", json={"permissions": ["DIRECTORY_VIEW"]})
|
||||
assert shrink.status_code == HTTP_OK
|
||||
assert (await expert.get("/me")).json()["permissions"] == ["DIRECTORY_VIEW"]
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_role_permissions_validation_and_last_admin(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
|
||||
admin = await auth_client(ADMIN)
|
||||
roles = {item["code"]: item for item in (await admin.get("/admin/roles")).json()}
|
||||
without_users_admin = [code for code in roles["ADMIN"]["permissions"] if code != "USERS_ADMIN"]
|
||||
|
||||
last = await admin.put("/admin/roles/ADMIN/permissions", json={"permissions": without_users_admin})
|
||||
assert last.status_code == HTTP_CONFLICT
|
||||
assert _detail_code(last) == "LAST_ADMIN"
|
||||
|
||||
unknown = await admin.put("/admin/roles/EXPERT/permissions", json={"permissions": ["KFO_VIEW", "FLY"]})
|
||||
assert unknown.status_code == HTTP_UNPROCESSABLE
|
||||
assert unknown.json()["detail"]["code"] == "UNKNOWN_PERMISSION"
|
||||
assert unknown.json()["detail"]["permissions"] == ["FLY"]
|
||||
missing = await admin.put("/admin/roles/NO_SUCH/permissions", json={"permissions": []})
|
||||
assert missing.status_code == HTTP_NOT_FOUND
|
||||
assert await _events(seeded, AuditAction.ROLE_PERMISSION_CHANGE) == []
|
||||
|
||||
# USERS_ADMIN есть у другой роли активного пользователя — снять с ADMIN можно
|
||||
await admin.put("/admin/roles/EXPERT/permissions", json={"permissions": [*EXPERT_PERMISSIONS, "USERS_ADMIN"]})
|
||||
allowed = await admin.put("/admin/roles/ADMIN/permissions", json={"permissions": without_users_admin})
|
||||
assert allowed.status_code == HTTP_OK, allowed.text
|
||||
assert "USERS_ADMIN" not in allowed.json()["permissions"]
|
||||
|
||||
|
||||
# --- Права доступа -----------------------------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_admin_routes_forbidden_without_users_admin(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
|
||||
client = await auth_client("ka_go@armka.local")
|
||||
user_id = await _user_id(seeded, "ka_rf@armka.local")
|
||||
calls = [
|
||||
("GET", USERS, None),
|
||||
("POST", USERS, {"email": "x@example.com", "full_name": "Х", "regional_office_id": str(uuid.uuid4())}),
|
||||
("PATCH", f"{USERS}/{user_id}", {"full_name": "Х"}),
|
||||
("PATCH", f"{USERS}/{uuid.uuid4()}", {"full_name": "Х"}),
|
||||
("PUT", f"{USERS}/{user_id}/roles", {"roles": ["ADMIN"]}),
|
||||
("GET", "/admin/roles", None),
|
||||
("GET", "/admin/permissions", None),
|
||||
("PUT", "/admin/roles/KA_GO/permissions", {"permissions": ["USERS_ADMIN"]}),
|
||||
("PUT", "/admin/roles/NO_SUCH/permissions", {"permissions": []}),
|
||||
("PATCH", f"{USERS}/not-a-uuid", {"bad": 1}),
|
||||
]
|
||||
for method, path, body in calls:
|
||||
response = await client.request(method, path, json=body)
|
||||
assert response.status_code == HTTP_FORBIDDEN, (method, path, response.text)
|
||||
assert response.json() == {"detail": "недостаточно прав: требуется USERS_ADMIN"}
|
||||
assert await _role_codes(seeded, user_id) == ["KA_RF"]
|
||||
|
||||
|
||||
# --- Гонка двух администраторов ----------------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def _remove_admin_role(maker: async_sessionmaker[AsyncSession], actor_id: uuid.UUID, target_id: uuid.UUID) -> str:
|
||||
"""Отдельная транзакция «как запрос»: `actor_id` заменяет роли `target_id` на `[EXPERT]`; итог — `ok` или код."""
|
||||
try:
|
||||
async with maker() as session, session.begin():
|
||||
audit = RequestAudit(service=AuditService(session), user_id=actor_id, ip_address=None, user_agent=None)
|
||||
await UsersAdminService(session, audit, actor_id).set_roles(target_id, ["EXPERT"])
|
||||
except DomainError as error:
|
||||
return error.code
|
||||
return "ok"
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_concurrent_mutual_admin_removal_keeps_one_admin(
|
||||
committed_sessionmaker: async_sessionmaker[AsyncSession], monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Два администратора в параллельных транзакциях снимают ADMIN друг с друга: успевает ровно один (D-8).
|
||||
|
||||
Без блокировки обе транзакции насчитали бы «другого» администратора и обе зафиксировались бы — ноль администраторов.
|
||||
Проверка на уровне сервиса: обе транзакции стартуют одновременно, без предварительной аутентификации запроса.
|
||||
Барьер перед `lock_permission` гарантирует, что обе транзакции уже прочитали своих пользователей и дошли до проверки
|
||||
до того, как любая из них зафиксируется (без барьера первая могла бы успеть целиком и гонки бы не было).
|
||||
"""
|
||||
barrier = asyncio.Barrier(2)
|
||||
original_lock = users_repo.lock_permission
|
||||
|
||||
async def lock_after_barrier(session: AsyncSession, code: str) -> None:
|
||||
async with asyncio.timeout(10):
|
||||
await barrier.wait()
|
||||
await original_lock(session, code)
|
||||
|
||||
monkeypatch.setattr(users_repo, "lock_permission", lock_after_barrier)
|
||||
|
||||
async with committed_sessionmaker() as session, session.begin():
|
||||
first_id = await _user_id(session, ADMIN)
|
||||
second = await make_user(
|
||||
session, email="second.admin@example.com", office=await _office(session, HEAD_OFFICE), roles=["ADMIN"]
|
||||
)
|
||||
second_id = second.id
|
||||
|
||||
results = await asyncio.gather(
|
||||
_remove_admin_role(committed_sessionmaker, first_id, second_id),
|
||||
_remove_admin_role(committed_sessionmaker, second_id, first_id),
|
||||
)
|
||||
assert sorted(results) == ["LAST_ADMIN", "ok"]
|
||||
|
||||
async with committed_sessionmaker() as session:
|
||||
remaining = await users_repo.count_active_users_with_permission(session, "USERS_ADMIN")
|
||||
assert remaining == 1
|
||||
@ -196,9 +196,9 @@ async def run(self, *, filename: str, content: bytes, dry_run: bool, on_existing
|
||||
- Схемы (`schemas/admin.py`): `UserCreate(email: EmailStr, full_name: str, regional_office_id: uuid.UUID, employee_no: str | None = None, business_segment: BusinessSegment | None = None, roles: list[str] = [])`, `UserUpdate` (все optional, `extra="forbid"`), `UserOut(id, email, full_name, employee_no, regional_office: RegionalOfficeOut, business_segment, is_active, roles: list[str], created_at, updated_at)`, `RolesSet(roles: list[str])`, `RoleOut(code, name, description, is_system, is_active, permissions: list[str])`, `PermissionOut(code, object_type, action, name, description)`, `PermissionsSet(permissions: list[str])`.
|
||||
- Маршруты (`require("USERS_ADMIN")`): `GET /admin/users` → `Page[UserOut]` (`q`, `is_active`), `POST /admin/users` → 201, `PATCH /admin/users/{id}`, `PUT /admin/users/{id}/roles` → `UserOut`, `GET /admin/roles` → `list[RoleOut]`, `GET /admin/permissions` → `list[PermissionOut]`, `PUT /admin/roles/{code}/permissions` → `RoleOut`.
|
||||
|
||||
- [ ] **Step 1: Тесты** (integration, `admin@armka.local`): создание с `roles=["KA_RF"]` → `app_user`+`user_role`; `GET /me` новым пользователем (через `auth_client(new_email)`) → права KA_RF; email в другом регистре → 409 `EMAIL_EXISTS`; `PUT roles` `[KA_GO, EXPERT]` → ровно две строки, аудит before/after; единственный админ снимает с себя `ADMIN` → 409 `LAST_ADMIN`; второй админ создан → снятие разрешено; `PATCH is_active=false` на себе как последнем → 409; матрица: `EXPERT` + `EXPORT` → `role_permission` строка, `/me` экспертом видит `EXPORT`, аудит `ROLE_PERMISSION_CHANGE`; убрать `USERS_ADMIN` у `ADMIN` → 409 `LAST_ADMIN`; `GET /admin/permissions` → 16 кодов; `ka_go` → 403 на всё.
|
||||
- [ ] **Step 2–4:** падает → реализация → тесты, `make check` → PASS.
|
||||
- [ ] **Step 5: Commit** `feat(admin): пользователи, роли, матрица прав — API с аудитом и защитой LAST_ADMIN`.
|
||||
- [x] **Step 1: Тесты** (integration, `admin@armka.local`): создание с `roles=["KA_RF"]` → `app_user`+`user_role`; `GET /me` новым пользователем (через `auth_client(new_email)`) → права KA_RF; email в другом регистре → 409 `EMAIL_EXISTS`; `PUT roles` `[KA_GO, EXPERT]` → ровно две строки, аудит before/after; единственный админ снимает с себя `ADMIN` → 409 `LAST_ADMIN`; второй админ создан → снятие разрешено; `PATCH is_active=false` на себе как последнем → 409; матрица: `EXPERT` + `EXPORT` → `role_permission` строка, `/me` экспертом видит `EXPORT`, аудит `ROLE_PERMISSION_CHANGE`; убрать `USERS_ADMIN` у `ADMIN` → 409 `LAST_ADMIN`; `GET /admin/permissions` → 16 кодов; `ka_go` → 403 на всё.
|
||||
- [x] **Step 2–4:** падает → реализация → тесты, `make check` → PASS.
|
||||
- [x] **Step 5: Commit** `feat(admin): пользователи, роли, матрица прав — API с аудитом и защитой LAST_ADMIN`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -63,7 +63,7 @@ test-integration`). Пути — от корня репозитория. Доп
|
||||
|
||||
## 4. Администрирование
|
||||
|
||||
- [ ] 4.1 **Пользователи и роли (API).** `services/users_admin.py` (создание с проверкой `lower(email)`, изменение, деактивация,
|
||||
- [x] 4.1 **Пользователи и роли (API).** `services/users_admin.py` (создание с проверкой `lower(email)`, изменение, деактивация,
|
||||
`PUT roles` с diff и `LAST_ADMIN`), `repositories/users.py` (+ запросы списка), эндпоинты `api/endpoints/admin/users.py`,
|
||||
`roles.py` (`GET roles`, `GET permissions`, `PUT roles/{code}/permissions` с `LAST_ADMIN` и защитой `is_system`),
|
||||
аудит `USER_CHANGE` / `USER_ROLE_CHANGE` / `ROLE_PERMISSION_CHANGE`. Тесты: сценарии спеки `users-admin` (создание с ролью →
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user