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
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:
133
src/apps/user/management.py
Normal file
133
src/apps/user/management.py
Normal 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
|
||||
Reference in New Issue
Block a user