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.

Маршрут вважається адміністративним, якщо шлях, оголошений у його @Controller(...), починається з admin/:

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 — єдина відповідь на питання про роль, і всі кличуть саме його, а не порівнюють рядки. Його кличе гвардія маршруту; його ж кличе вхід до адмінки, якому гвардія не підходить зовсім, бо відмовити він мусить до видачі токена. Одне рішення, двоє дверей.