Роль администратора платформы
В Agentfy два разных администратора, и вся эта страница — о том, как их не перепутать.
- Администратор команды управляет одной командой. Это
UserTeam.role, и его верхнее значение —admin. - Администратор платформы может смотреть во все команды. Это
User.platformRole, и его единственное значение — тожеadmin.
Пишутся одинаково, означают совершенно разное. Если их спутать, тот, кто управляет одной командой, получит доступ ко всем остальным — и по ответу этого не будет видно, потому что запрос просто выполнится. Всё, что ниже, сделано для того, чтобы эту ошибку было трудно совершить.
Сама роль
| Где живёт | User.platformRole — колонка, допускающая пустое значение |
| Значения | null (все) или 'admin' |
| Уровней | Один. Второй потом добавить дёшево, а лишний убрать дорого |
| Откуда читается | Из базы, на каждом запросе — никогда из токена |
Поскольку роль читается на каждом запросе, а не лежит в токене, отзыв действует немедленно, а не до истечения токена. Промежуток, в котором бывший администратор всё ещё видит всех арендаторов, — это ровно тот промежуток, которого быть не должно.
Основатель — единственная выдача, которую никто не авторизует
Иначе свежая установка была бы запертой дверью с ключом внутри: панель никому не видна, потому что администратора нет, а сделать его можно только из оболочки на базе. Поэтому самая ранняя учётная запись становится администратором платформы, один раз.
Слово «один раз» надо читать строго, потому что соблазнительная версия этого правила — как раз опасная. «Если администраторов нет — повысить самого раннего» звучит безобидно, пока система новая, и не истекает никогда: отзовите последнего администратора на установке с тысячей пользователей — и роль тихо достанется тому, кто зарегистрировался первым, без единой ошибки в коде.
Поэтому условие — не «есть ли сейчас хоть один администратор». Это строка, которая фиксирует, что у этой установки основатель уже был. Её ничто не удаляет, и удаление учётной записи основателя её тоже не уносит, потому что адрес хранится там текстом, а не связью. Отменить это значит осознанно удалить строку из таблицы, всё содержимое которой — утверждение, что дверь закрыта.
- Две двери, одно решение. Проверка идёт на старте api — это покрывает базу, которая старше этого кода и где регистрации может не быть неделями, — и ещё раз при создании учётной записи, что покрывает по-настоящему свежую установку: ждать следующего перезапуска означало бы, что основатель зарегистрировался, панели не увидел и должен перезапустить сервер, который только что поднял.
- Если администратор уже есть, установка помечается как основанная, и никого не повышают.
- Ровно один при гонке. Две одновременные регистрации не могут основать установку обе; решает база, а не проверка перед записью.
- Это громко. Роль выдаётся, когда рядом нет никого, кто бы её авторизовал, поэтому остаётся только след: строка в обычном выводе лога о том, кого повысили и на каком основании.
Как её выдают — всем, кроме основателя
Ни эндпоинта, ни кнопки нет — не ищите.
cd api
node scripts/platformAdmin.mjs list
node scripts/platformAdmin.mjs grant someone@example.com
node scripts/platformAdmin.mjs revoke someone@example.comДать человеку возможность видеть всех арендаторов — самое привилегированное действие в продукте, и первое действие, которому нужен журнал, — именно то, которое эту привилегию раздаёт. Журнала пока нет, поэтому действие остаётся там, где оно и так учтено: в консоли, доступ к которой кому-то доверили, и в базе, до которой кто-то смог дотянуться. Сделать кнопку первой означало бы построить самое опасное действие продукта ровно в тот момент, когда его нечем записать.
Через HTTP роль не выставить ничем: её нет в типах создания и обновления, поэтому её не принесёт ни одно тело запроса, и нет в UserDto, поэтому её не покажет ни один ответ.
Как защищены административные маршруты
PlatformAdminGuard регистрируется один раз, глобальной гвардией, в AdminGroupModule. Контроллеры не пишут @UseGuards.
Маршрут считается административным, если его объявленный адрес начинается с admin/ — неважно, в каком декораторе написан этот первый сегмент. Nest складывает адрес из двух объявлений, и гвардия читает оба:
@Controller('admin/team') // защищён — писать больше нечего
export class AdminTeamController {
@Get(':id') read() {} // → admin/team/:id
}
@Controller() // тоже защищён — считается адрес, а не декоратор
export class AdminAuditController {
@Get('admin/audit') read() {} // → admin/audit
}Считается только тот сегмент, который действительно ведёт адрес, а ведёт его путь контроллера, если контроллер его объявил. Поэтому admin дальше по адресу признаком не является — и не должен:
@Controller('team')
export class TeamController {
@Get('admin') admins() {} // → team/admin — обычный продуктовый маршрут
}Признаком выбран объявленный адрес, а не декоратор, потому что декоратор можно забыть, а забытая проверка доступа невидима — эндпоинт просто работает. Адрес — это единственное, что административный эндпоинт не может не объявить: помнить нечего, а значит и забыть нечего.
Гвардия смотрит на объявленный адрес, а не на URL запроса. Глобальный префикс, точка монтирования или переписывание пути на прокси могут добавить сегменты перед /admin в URL; ни одно из них не сдвинет то, что автор написал в декораторах. По той же причине никакое написание URL не обходит гвардию: заглавные буквы, косая черта на конце и вложенный сегмент ведут к тому же объявлению и к тому же отказу.
Два исключения:
@PlatformAdmin()помечает административный маршрут, которому пришлось жить вне префикса.@Public()учитывается. Это дверь входа: она должна быть открыта тому, кто ещё никто, и она сама отказывает не-администраторам — до выдачи токена, тем же предикатом.
Что получает вызывающий
| Ситуация | Ответ |
|---|---|
| Токена нет или он протух | 401 |
| Токен верный, но это не администратор платформы | 403 NOT_PLATFORM_ADMIN |
| Администратор платформы | 200 |
Коды означают разное, и админка на этой разнице строит поведение: 401 имеет смысл повторить со свежим токеном, 403 — никогда, новый токен скажет ровно то же самое.
Что администратор видит
Всё, кроме содержимого переписок. Команды, участники, агенты, счётчики, даты и статусы видны; тела сообщений в чатах и содержимое памяти агентов — нет.
Расширить видимость потом стоит одной правки; сузить — не вернёт прочитанного. Поэтому экран обзора может показать, что в переписке сорок сообщений и когда пришло последнее, и не может показать, что в них было написано.
Как проверить своё положение
GET /admin/accessВозвращает идентификатор, адрес и платформенную роль вызывающего. Никаких действий не выполняет — это основание для доступа и ничего больше. Действия придут вместе с журналом, который обязан их записывать.
Для кода
import { isPlatformAdmin, PlatformRoleTypes } from '#user/user/domain';isPlatformAdmin — единственный ответ на вопрос о роли, и все зовут именно его, а не сравнивают строки. Его зовёт гвардия маршрута; его же зовёт вход в админку, которому гвардия не подходит вовсе, потому что отказать он обязан до выдачи токена. Одно решение, две двери.