feat(users): add transactional employee deletion and token revocation
All checks were successful
State Corp Backend CI/CD / Quality gate (push) Successful in 2m42s
State Corp Backend CI/CD / Build linux/amd64 images once (push) Successful in 5m29s
State Corp Backend CI/CD / Refresh and release internal main (push) Has been skipped
State Corp Backend CI/CD / Release customer main (push) Has been skipped
State Corp Backend CI/CD / Release dev (push) Successful in 36s

This commit is contained in:
Aleksandr Meshchryakov
2026-09-13 22:55:31 +02:00
parent ef087d5559
commit e99616ed6d
9 changed files with 971 additions and 69 deletions

133
src/apps/user/management.py Normal file
View File

@@ -0,0 +1,133 @@
"""Transactional guards shared by account management API and Django admin."""
import logging
from collections.abc import Iterable, Iterator
from contextlib import contextmanager
from apps.core.exceptions import (
AuthenticationError,
BadRequestError,
ConflictError,
NotFoundError,
PermissionDeniedError,
)
from apps.core.models import BackgroundJob
from django.core.files.storage import Storage
from django.db import transaction
from django.db.models import Q
from django.db.models.deletion import ProtectedError
from rest_framework_simplejwt.token_blacklist.models import OutstandingToken
from .models import Profile, User
logger = logging.getLogger(__name__)
@contextmanager
def lock_active_user(user_id: int) -> Iterator[User]:
"""Keep an in-flight self-service save from recreating a deleted account."""
with transaction.atomic():
try:
user = User.objects.select_for_update().get(pk=user_id)
except User.DoesNotExist as exc:
raise NotFoundError("Пользователь не найден.") from exc
if not user.is_active:
raise AuthenticationError("Учётная запись недоступна.")
yield user
@contextmanager
def lock_managed_users(
*, actor_id: int, user_ids: Iterable[int]
) -> Iterator[dict[int, User]]:
"""Serialize changes that can remove an administrator and recheck the actor."""
target_ids = set(user_ids)
with transaction.atomic():
users = {
user.pk: user
for user in User.objects.select_for_update()
.filter(
Q(is_active=True, is_staff=True) | Q(pk__in=target_ids | {actor_id})
)
.order_by("pk")
}
# A request may have authenticated before another administrator deleted or
# demoted its actor. Never authorize from the stale request.user object.
actor = users.get(actor_id)
if actor is None or not actor.is_active or not actor.is_staff:
raise PermissionDeniedError(
"Доступ разрешён только активному администратору."
)
if target_ids - users.keys():
raise NotFoundError("Пользователь не найден.")
yield {user_id: users[user_id] for user_id in target_ids}
def ensure_admins_remain(*, removed_ids: Iterable[int]) -> None:
"""Require a remaining active staff account while holding management locks."""
if (
not User.objects.filter(is_active=True, is_staff=True)
.exclude(pk__in=removed_ids)
.exists()
):
raise ConflictError(
"Нельзя удалить или отключить последнего активного администратора.",
code="last_active_admin",
)
def validate_access_change(
user: User, *, actor_id: int, is_active: bool, is_staff: bool
) -> None:
"""Validate a proposed status/role change under the management locks."""
if user.pk == actor_id and (not is_active or not is_staff):
raise BadRequestError(
"Нельзя деактивировать себя или снять у себя роль администратора.",
code="self_deactivation_forbidden",
)
if user.is_active and user.is_staff and not (is_active and is_staff):
ensure_admins_remain(removed_ids=[user.pk])
def _delete_unused_avatar(*, storage: Storage, name: str, user_id: int) -> None:
"""Clean up a committed deletion without removing another profile's file."""
try:
if not Profile.objects.filter(avatar=name).exists():
storage.delete(name)
except Exception: # noqa: BLE001
# The account is already deleted. Storage failure must not pretend the
# transaction failed or disclose a storage path in an API response.
logger.error("Avatar cleanup failed after deleting user id=%s", user_id)
def delete_accounts(*, actor_id: int, user_ids: Iterable[int]) -> None:
"""Delete accounts atomically, preserving business records and their files."""
with lock_managed_users(actor_id=actor_id, user_ids=user_ids) as users:
if actor_id in users:
raise BadRequestError(
"Нельзя удалить самого себя.", code="self_delete_forbidden"
)
ensure_admins_remain(removed_ids=users)
for user in users.values():
profile = Profile.objects.filter(user_id=user.pk).first()
avatar = profile.avatar if profile is not None else None
if avatar:
storage, name, user_id = avatar.storage, avatar.name, user.pk
transaction.on_commit(
lambda storage=storage,
name=name,
user_id=user_id: _delete_unused_avatar(
storage=storage, name=name, user_id=user_id
)
)
# OutstandingToken uses SET_NULL, while BackgroundJob stores a plain
# integer. Neither is cleaned up automatically by User.delete().
OutstandingToken.objects.filter(user_id=user.pk).delete()
BackgroundJob.objects.filter(user_id=user.pk).update(user_id=None)
try:
user.delete()
except ProtectedError as exc:
raise ConflictError(
"Связанные записи препятствуют удалению пользователя.",
code="user_delete_conflict",
) from exc