Skip to content

Роль администратора платформы

В Agentfy два разных администратора, и вся эта страница — о том, как их не перепутать.

  • Администратор команды управляет одной командой. Это UserTeam.role, и его верхнее значение — admin.
  • Администратор платформы может смотреть во все команды. Это User.platformRole, и его единственное значение — тоже admin.

Пишутся одинаково, означают совершенно разное. Если их спутать, тот, кто управляет одной командой, получит доступ ко всем остальным — и по ответу этого не будет видно, потому что запрос просто выполнится. Всё, что ниже, сделано для того, чтобы эту ошибку было трудно совершить.

Сама роль

Где живётUser.platformRole — колонка, допускающая пустое значение
Значенияnull (все) или 'admin'
УровнейОдин. Второй потом добавить дёшево, а лишний убрать дорого
Откуда читаетсяИз базы, на каждом запросе — никогда из токена

Поскольку роль читается на каждом запросе, а не лежит в токене, отзыв действует немедленно, а не до истечения токена. Промежуток, в котором бывший администратор всё ещё видит всех арендаторов, — это ровно тот промежуток, которого быть не должно.

Основатель — единственная выдача, которую никто не авторизует

Иначе свежая установка была бы запертой дверью с ключом внутри: панель никому не видна, потому что администратора нет, а сделать его можно только из оболочки на базе. Поэтому самая ранняя учётная запись становится администратором платформы, один раз.

Слово «один раз» надо читать строго, потому что соблазнительная версия этого правила — как раз опасная. «Если администраторов нет — повысить самого раннего» звучит безобидно, пока система новая, и не истекает никогда: отзовите последнего администратора на установке с тысячей пользователей — и роль тихо достанется тому, кто зарегистрировался первым, без единой ошибки в коде.

Поэтому условие — не «есть ли сейчас хоть один администратор». Это строка, которая фиксирует, что у этой установки основатель уже был. Её ничто не удаляет, и удаление учётной записи основателя её тоже не уносит, потому что адрес хранится там текстом, а не связью. Отменить это значит осознанно удалить строку из таблицы, всё содержимое которой — утверждение, что дверь закрыта.

  • Две двери, одно решение. Проверка идёт на старте api — это покрывает базу, которая старше этого кода и где регистрации может не быть неделями, — и ещё раз при создании учётной записи, что покрывает по-настоящему свежую установку: ждать следующего перезапуска означало бы, что основатель зарегистрировался, панели не увидел и должен перезапустить сервер, который только что поднял.
  • Если администратор уже есть, установка помечается как основанная, и никого не повышают.
  • Ровно один при гонке. Две одновременные регистрации не могут основать установку обе; решает база, а не проверка перед записью.
  • Это громко. Роль выдаётся, когда рядом нет никого, кто бы её авторизовал, поэтому остаётся только след: строка в обычном выводе лога о том, кого повысили и на каком основании.

Как её выдают — всем, кроме основателя

Ни эндпоинта, ни кнопки нет — не ищите.

bash
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 складывает адрес из двух объявлений, и гвардия читает оба:

ts
@Controller('admin/team')      //  защищён — писать больше нечего
export class AdminTeamController {
  @Get(':id') read() {}        //  → admin/team/:id
}

@Controller()                  //  тоже защищён — считается адрес, а не декоратор
export class AdminAuditController {
  @Get('admin/audit') read() {}  //  → admin/audit
}

Считается только тот сегмент, который действительно ведёт адрес, а ведёт его путь контроллера, если контроллер его объявил. Поэтому admin дальше по адресу признаком не является — и не должен:

ts
@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

Возвращает идентификатор, адрес и платформенную роль вызывающего. Никаких действий не выполняет — это основание для доступа и ничего больше. Действия придут вместе с журналом, который обязан их записывать.

Для кода

ts
import { isPlatformAdmin, PlatformRoleTypes } from '#user/user/domain';

isPlatformAdmin — единственный ответ на вопрос о роли, и все зовут именно его, а не сравнивают строки. Его зовёт гвардия маршрута; его же зовёт вход в админку, которому гвардия не подходит вовсе, потому что отказать он обязан до выдачи токена. Одно решение, две двери.