Andrey Epifancev 800f3a49c4 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
2026-10-05 00:07:07 +04:00

262 lines
13 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 authn-authz, D-5, R-17).
`effective_permissions(user) = ∪ permission.code по всем активным ролям пользователя` (CLAUDE.md: права — данные).
Код роли в проверках не участвует: роли возвращаются только для показа (`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 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
class AmbiguousUserError(Exception):
"""По email найдено больше одного пользователя (email в БД уникален с учётом регистра, сравнение — без него)."""
@dataclass(frozen=True)
class UserWithAccess:
"""Пользователь, его филиал, коды активных ролей и эффективные права (коды).
`roles` — отсортированный кортеж кодов активных ролей; `permissions` — объединение прав этих ролей без дубликатов.
"""
user: AppUser
regional_office: RegionalOffice
roles: tuple[str, ...]
permissions: frozenset[str]
async def load_principal_data(session: AsyncSession, email: str) -> UserWithAccess | None:
"""Пользователь по email (без учёта регистра) вместе с филиалом, ролями и правами — ОДИН SQL-запрос.
Возвращает `None`, если пользователя нет; бросает :class:`AmbiguousUserError`, если подходит больше одного
(fail-closed, R-17). Неактивный пользователь возвращается как есть (решение — за вызывающим). Роль с
`is_active=false` не даёт прав и в `roles` не попадает.
"""
matched = (
select(AppUser)
.where(func.lower(AppUser.email) == email.lower())
.order_by(AppUser.id)
.limit(_AMBIGUITY_PROBE_LIMIT)
.subquery()
)
user = aliased(AppUser, matched)
statement = (
select(user, RegionalOffice, Role.code, Permission.code)
.join(RegionalOffice, RegionalOffice.id == user.regional_office_id)
.outerjoin(UserRole, UserRole.user_id == user.id)
.outerjoin(Role, and_(Role.id == UserRole.role_id, Role.is_active.is_(True)))
.outerjoin(RolePermission, RolePermission.role_id == Role.id)
.outerjoin(Permission, Permission.id == RolePermission.permission_id)
)
rows = (await session.execute(statement)).all()
if not rows:
return None
users = {row[0].id for row in rows}
if len(users) > 1:
raise AmbiguousUserError(email)
roles = {row[2] for row in rows if row[2] is not None}
permissions = frozenset(row[3] for row in rows if row[3] is not None)
return UserWithAccess(
user=rows[0][0],
regional_office=rows[0][1],
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()