"""Сервис администрирования (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().lower() if await repo.email_taken(self._session, email): 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)