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