Add role management validation to prevent removal of the last admin
CI / Backend (build + test) (push) Successful in 1m20s
CI / Frontend (lint + typecheck + build) (push) Successful in 34s

- Introduced a new error, `CannotRemoveLastAdmin`, to handle attempts to downgrade the last admin user in the system.
- Updated `RoleService` to check the number of admin users before allowing a role change that would remove the last admin.
- Enhanced unit tests to verify the new behavior, ensuring that attempts to downgrade the last admin correctly propagate the failure.
- Updated API documentation to reflect the new validation logic and its implications for role management.
This commit is contained in:
Leonid Pershin
2026-07-14 23:53:20 +03:00
parent 940577d8cd
commit f14daa3df1
8 changed files with 312 additions and 2 deletions
+6 -1
View File
@@ -190,6 +190,11 @@ Support.CannotRequestAdminRole`), либо все три поля новой р
| POST | `/api/admin/support/tickets/{id}/approve` | — | `204 No Content` (только `RoleRequest`/`Open`; создаёt/назначает роль) |
| POST | `/api/admin/support/tickets/{id}/reject` | `{ reason? }` | `204 No Content` (только `RoleRequest`; `reason` уходит комментарием) |
Обработать **собственный** тикет админу можно (в т.ч. одобрить свою же заявку на роль) — resolve/close/
reject/approve владением тикета не ограничены. Единственное реальное ограничение — `approve` вернёт
`409 Roles.CannotRemoveLastAdmin` через `ChangeUserRoleAsync`, если заявка (своя или чужая) снимает
`admin` с последнего администратора в системе.
`approve`/`reject` — единственный способ решить заявку на роль (нельзя одобрить через `resolve`).
При одобрении: если заявка на существующую роль — сразу `ChangeUserRoleCommand`-эквивалент; если на
новую — сперва создаётся `AppRole` (`IRoleService.CreateRoleAsync`), затем назначается. То же самое
@@ -242,7 +247,7 @@ Support.CannotRequestAdminRole`), либо все три поля новой р
| POST | `/api/admin/roles` | admin | `{ name, maxConfigs, maxIpLimit }` | `RoleDto` |
| PUT | `/api/admin/roles/{id}` | admin | `{ maxConfigs, maxIpLimit }` | `RoleDto` |
| DELETE | `/api/admin/roles/{id}` | admin | — | `204 No Content` (системные `admin`/`user` удалить нельзя) |
| PATCH | `/api/admin/users/{id}/role` | admin | `{ roleId }` | `204 No Content` |
| PATCH | `/api/admin/users/{id}/role` | admin | `{ roleId }` | `204 No Content` (`409 Roles.CannotRemoveLastAdmin`, если у цели сейчас `admin`, новая роль другая, и это единственный админ) |
Нет отдельного эндпоинта «активировать напрямую без запроса» — активация только через
approve/reject над `ActivationRequest`.
+14
View File
@@ -281,6 +281,14 @@ UI **настойчиво напоминает** привязать его (ед
конфигов больше новой квоты — существующие конфиги сохраняются, но **создание новых блокируется**,
пока число активных не станет меньше квоты. Форс-отзыв лишних не делаем.
**Нельзя снять `admin` с последнего администратора**: `IRoleService.ChangeUserRoleAsync` перед сменой
роли проверяет — если у пользователя сейчас `admin`, а новая роль другая, и админов в системе ровно
один — `RoleErrors.CannotRemoveLastAdmin` (409), смены не происходит. Единая точка защиты — работает
и при прямой смене роли из `/admin/users`, и при одобрении заявки на роль через `SupportTicket`
(`ApproveRoleRequestCommandHandler` вызывает тот же `ChangeUserRoleAsync`), в том числе когда админ
одобряет заявку на понижение самому себе — этот путь специально не блокируется отдельно, чтобы не
плодить тикеты, которые некому обработать, если админ единственный.
### ActivationRequest — запрос активации
Пользователь просит активацию у админа; админ одобряет/отклоняет на сайте или в Telegram.
@@ -355,6 +363,12 @@ UI **настойчиво напоминает** привязать его (ед
- Доступ — только активированному пользователю (`IRequiresActivation`, как и у конфигов/новостей);
админские действия (resolve/close/approve/reject) идут по отдельным `/api/admin/support/*` с
ролевой проверкой, без завязки на активацию.
- Resolve/close/reject **собственного** тикета админом разрешены — они не трогают роль, риска нет
(запрет ломал бы самообслуживание: тикет единственного админа застревал бы в `Open` навсегда, убрать
некому). Единственное действие с реальным риском — approve заявки на роль, потому что оно меняет
роль заявителя; его самостоятельная защита не нужна — она уже есть на уровень ниже, см. `AppRole`
(`RoleErrors.CannotRemoveLastAdmin`), и одинаково работает что для approve своей заявки, что для
прямой смены роли через `/admin/users`.
- `Closed`-тикеты не удаляются автоматически — админ может подчистить их вручную (вкладка
«Обслуживание», `DELETE /api/admin/maintenance/tickets/closed`), это удаляет и `TicketComment`/
`TicketAttachment` (+ файлы на диске), необратимо.