Роль адміністратора платформи
В 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.
Маршрут вважається адміністративним, якщо шлях, оголошений у його @Controller(...), починається з admin/:
@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 — єдина відповідь на питання про роль, і всі кличуть саме його, а не порівнюють рядки. Його кличе гвардія маршруту; його ж кличе вхід до адмінки, якому гвардія не підходить зовсім, бо відмовити він мусить до видачі токена. Одне рішення, двоє дверей.