arm-core/api/src/services/users_admin.py
Andrey Epifancev ab606dc59a feat(model): DBML v5.15 — osv_data_type NU, email в нижнем регистре, миграция 0003
- OsvDataType.NU после BU, DBML_VERSION = 5.15, Makefile/dbml_parity/docstring'и на data-model-v5.15.dbml;
- миграция 0003_dbml_v5_15: ADD VALUE 'NU' AFTER 'BU' + lower(app_user.email); downgrade пересоздаёт osv_data_type
  (регистр email не возвращается);
- email хранится в нижнем регистре: users_admin.create_user и seed (_apply_users); с UNIQUE по строчному email гонка
  создания в другом регистре теперь ловится индексом (тест test_api_constraint_races обновлён);
- тесты: enum NU, откат на шаг, lower email на миграции, seed/админка, вставка NU, фиктивная v5.16.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
2026-10-05 21:28:04 +04:00

303 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Сервис администрирования (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)