Тема
ТЗ — API-токены (Sanctum) (cms/api-tokens)
Слой: 🔵 инфра-модуль · Зрелость доноров: ★★ · Донор: freelance Статус: ТЗ к разработке
Назначение и возможности
Административный слой поверх Sanctum: управление персональными токенами доступа из Filament без командной строки. Даёт видимость abilities, срока действия и активности токена, а также журнал его использования.
- Выпуск токена с явным набором abilities и сроком действия;
- список токенов пользователя с датой последнего использования;
- принудительный отзыв токена (мгновенная инвалидация);
- журнал использования токена (эндпоинт, IP, дата) для аудита;
- rate-limit per-токен поверх общего лимита API;
- уведомление владельцу при использовании токена с нового IP (интеграция с
cms/notifications-bus).
Зависимости и выключение
requires: — · suggests: cms/notifications-bus · provides: api-tokens
При выключении управление токенами из Filament недоступно; ранее выпущенные токены продолжают работать через штатный Sanctum, но без rate-limit per-токен и журнала.
Модель данных
| Таблица | Ключевые поля | Примечание |
|---|---|---|
cms_api_token_meta | token_id (FK на personal_access_tokens), label, expires_at, rate_limit, last_ip | Расширение стандартной таблицы Sanctum |
cms_api_token_usage_log | token_id, route, ip, used_at | Журнал вызовов (ротация по retention) |
token_id — constrained('personal_access_tokens')->index(), expires_at — cast datetime, last_ip — nullable string, денормализация последнего IP (обновляется вместе с last_used_at тем же обработчиком, что пишет cms_api_token_usage_log — источник истины по истории IP; last_ip — только для списка без похода в журнал). cms_api_token_usage_log — журнальная append-only таблица, индекс/BRIN по used_at под retention и выборку в UI.
Whitelist abilities: допустимые значения при выпуске — не произвольные строки, а permissions, зарегистрированные манифестами включённых модулей (extra.cms.permissions, §2 стандарта); whitelist строится динамически из реестра ядра на момент валидации (Filament и FormRequest читают один реестр, не дублируют список), не хардкодится. Permission выключенного модуля из реестра выпадает — попытка указать его отклоняется как неизвестная строка; уже выпущенный токен с такой ability описан в «Крайние случаи» (403 на проверке, не 500).
Входные и выходные данные
Входы — всё, что не перечислено ниже, отвергается (422 либо игнорируется, whitelist-принцип §11 стандарта):
| Источник | Данные/поля | Чем валидируется |
|---|---|---|
| Filament-форма выпуска токена | label, abilities[], ttl_days (или expires_at), rate_limit (опц.) | Валидация Filament: whitelist abilities из зарегистрированных permissions, ttl_days ≤ max_ttl_days, abilities не пустой массив |
POST /api/v1/admin/api-tokens | label, abilities[], ttl_days | FormRequest: whitelist abilities по правам вызывающего, ttl_days в допустимом диапазоне, заголовок Idempotency-Key |
DELETE /api/v1/admin/api-tokens/{id} | id токена | Route model binding + Policy (api-tokens.manage либо api-tokens.manage.own на свой токен) |
GET /api/v1/admin/api-tokens/{id}/usage | id токена, cursor | Route model binding + whitelist сортировки (только used_at) |
| Sanctum-middleware (внутренний перехват) | route, ip, token_id текущего запроса | Не пользовательский ввод — служебные поля запроса ядра, тело запроса не читается |
| Импорт / вебхук | — | Не применимо: модуль не принимает внешние импорт-файлы и вебхуки |
Выходы
| Потребитель | Данные | Формат |
|---|---|---|
| Filament / API-клиент (ответ на выпуск) | plaintext-токен (единственный раз), id, label, abilities[], expires_at | JSON {data, meta}; plaintext только в теле этого ответа, не логируется и не хранится |
| Filament / API (список токенов) | токены без plaintext, last_used_at, last_ip | keyset-пагинация {data, meta.next_cursor} |
| Filament / API (журнал использования) | route, ip, used_at | keyset-пагинация {data, meta.next_cursor} |
cms/notifications-bus (через NotificationDispatch) | user_id, token_id, ip | Payload provides-контракта; при выключенном модуле — log-fallback |
Подписчики события (например cms/audit, если включён) | token_id, user_id, abilities[] | Payload события ApiTokenIssued / ApiTokenRevoked |
Настройки (группа api-tokens)
| Ключ | Тип | Дефолт | affectsPageCache | Описание |
|---|---|---|---|---|
api-tokens.default_ttl_days | int | 90 | — | Срок действия токена по умолчанию |
api-tokens.default_rate_limit | int | 60 | — | Запросов в минуту на токен по умолчанию |
api-tokens.usage_log_retention_days | int | 30 | — | Хранение журнала использования |
api-tokens.notify_new_ip | bool | true | — | Уведомлять владельца при входе с нового IP |
api-tokens.max_ttl_days | int | 365 | — | Верхняя граница ttl_days при выпуске — блокирует фактически бессрочные токены |
api-tokens.max_active_per_user | int | 10 | — | Лимит активных токенов на пользователя — выпуск сверх лимита отклоняется понятной ошибкой |
api-tokens.rotation_grace_hours | int | 0 | — | Grace-период при ротации: 0 — старый токен отзывается мгновенно, >0 — работает до истечения окна |
api-tokens.issuance_enabled | bool | true | — | Kill-switch: false отключает выпуск новых токенов (существующие продолжают работать), без выключения модуля целиком |
api-tokens.auto_revoke_inactive_days | int | 180 | — | Токен без использования дольше этого срока отзывается автоматически джобой RevokeInactiveTokens; 0 — авто-отзыв выключен |
API
| Метод | Путь | Доступ | Назначение |
|---|---|---|---|
| GET | /api/v1/admin/api-tokens | api-tokens.manage | Список токенов (keyset-пагинация; свои или всех — по правам) |
| POST | /api/v1/admin/api-tokens | api-tokens.manage | Выпуск токена (abilities, ttl) |
| DELETE | /api/v1/admin/api-tokens/{id} | api-tokens.manage | Отзыв токена |
| GET | /api/v1/admin/api-tokens/{id}/usage | api-tokens.manage | Журнал использования конкретного токена (keyset-пагинация) |
POST /api/v1/admin/api-tokens обязан принимать заголовок Idempotency-Key — выпуск токена создаёт кред (мутация с внешним эффектом, §7 стандарта); ревизия ядра 14.07.2026, п.8 явно распространяет идемпотентность на выпуск токенов/ключей — повтор с тем же ключом отдаёт исходный ответ, не создаёт второй токен.
Компоненты
Filament: ресурс токенов (выпуск/отзыв, abilities-чекбоксы, дата последнего использования, last_ip), виджет «токены на истечении». Команды: cms:api-tokens:prune-expired --json (очистка просроченных), cms:api-tokens:doctor --json.
Demo-сидер: ApiTokenDemoSeeder создаёт один тестовый токен с ограниченными abilities (например pages.view) для playground/_gallery — виджет «токены на истечении» и ресурс списка показываются без ручного выпуска токена; plaintext demo-токена не хранится за пределами сидера (не публикуется в галерею как секрет).
События и обмен
| Событие | Когда | Payload |
|---|---|---|
ApiTokenIssued | Выпуск нового токена | token_id, user_id, abilities[] |
ApiTokenRevoked | Отзыв токена | token_id, user_id |
ApiTokenUsedFromNewIp | Первое использование с нового IP | token_id, ip |
Провайдер facts для cms/notifications-bus: ApiTokenUsedFromNewIp резолвится в уведомление владельцу через контракт notification-bus (канал по preferences). Своих provides-контрактов и фильтров FilterBus нет. Слушает только собственные запросы Sanctum-middleware (перехват использования токена для журнала).
Взаимодействия
| Сущность/модуль | Канал | Направление | Что происходит |
|---|---|---|---|
Ядро: пользователи (users) | сервис-вызов (core-contracts) | in | Токен привязан к владельцу; список токенов и abilities читаются через модель User/relation Sanctum, без raw SQL |
Ядро: настройки (SettingsStore) | сервис-вызов (core-contracts) | in | Чтение группы api-tokens из кеша группы (0 запросов на горячем пути) |
| Ядро: аутентификация (Sanctum) | сервис-вызов (core-contracts) | in/out | Проверка токена и auth:sanctum — часть ядра; модуль только слушает факт использования (журнал) и добавляет rate-limit поверх |
cms/notifications-bus (suggests) | provides-контракт (NotificationDispatch) | out | ApiTokenUsedFromNewIp → уведомление владельцу; модуль выключен → log-fallback, письмо не уходит |
cms/audit (если включён) | событие | out | Слушает ApiTokenIssued / ApiTokenRevoked для журнала действий; api-tokens не знает о его существовании |
Фоновая работа
Очередь api-tokens: джоба PruneExpiredTokens (идемпотентна) по расписанию через ScheduleRegistrar ядра, частота — из usage_log_retention_days. Джоба RevokeInactiveTokens (идемпотентна) — по тому же расписанию, отзывает токены без активности дольше api-tokens.auto_revoke_inactive_days (см. «Настройки»), 0 — джоба выключена. Отправка уведомления о новом IP — асинхронно, через cms/notifications-bus.
Ранбук (типовые инциденты):
| Симптом | Что проверить | Команда |
|---|---|---|
| Токены массово не проходят аутентификацию | Кеш группы api-tokens в SettingsStore (не протух ли и не содержит ли стейл-значение issuance_enabled), доступность Sanctum-middleware ядра | cms:api-tokens:doctor --json |
| Подозрение на утечку токена | Список активных токенов пользователя, last_ip/last_used_at в cms_api_token_meta; массовый отзыв с карточки пользователя | ApiTokenRevoked в аудите (если cms/audit включён) после массового отзыва |
Журнал использования не растёт / очередь api-tokens отстаёт | Длина очереди api-tokens, воркер запущен, PruneExpiredTokens/RevokeInactiveTokens не падают на середине | cms:api-tokens:doctor --json, вручную cms:api-tokens:prune-expired --json |
Бэкап/рестор: в бэкап попадают обе таблицы модуля — cms_api_token_meta и cms_api_token_usage_log (журнал ротируется по retention, но на момент бэкапа входит целиком). Денормализованных агрегатов и поисковых индексов модуль не хранит — last_ip/last_used_at в cms_api_token_meta восстанавливаются вместе со строкой, отдельной командой после рестора пересчитывать нечего.
Производительность и кеш
Ожидаемые объёмы (студийный проект, не highload): активных пользователей API — десятки-сотни; токенов на пользователя — 1–5 (основной сайт, мобильное приложение, интеграция с CRM, личный доступ). cms_api_token_usage_log: при активной интеграции с частотой 10–50 запросов/мин это 15–70 тыс. строк/сутки на токен, при retention 30 дней (дефолт) — до нескольких миллионов строк в журнале суммарно. Таблица спроектирована как append-only с BRIN-индексом по used_at именно под этот объём.
Горячие пути и бюджет запросов:
- проверка токена на каждом API-запросе (
auth:sanctum) — путь ядра, api-tokens не добавляет к нему синхронных запросов сверх необходимого; - запись в
cms_api_token_usage_logпроисходит на каждом аутентифицированном запросе — синхронныйINSERTна каждый вызов не проходит бюджет горячего пути; пишется асинхронно через очередьapi-tokensлибо буферизуется (батч-инсерт раз в N секунд/строк); обновлениеlast_ip/last_used_atвcms_api_token_meta— тем же асинхронным обработчиком, отдельного синхронногоUPDATEна горячем пути нет; - rate-limit per-токен — атомарный инкремент в Redis (тот же механизм, что общий API rate-limit ядра), запросов к БД не делает;
- список токенов пользователя и виджет «токены на истечении» — низкочастотные admin-запросы, отдельного бюджета не требуют.
Критичные индексы (см. «Модель данных»): token_id на cms_api_token_meta (constrained()->index()); составной token_id + used_at на cms_api_token_usage_log — под keyset-выборку журнала конкретного токена; BRIN по used_at — под очистку по retention; expires_at на cms_api_token_meta — под виджет «токены на истечении» и джобу PruneExpiredTokens (оба фильтруют по этому полю — без индекса полный скан таблицы). RevokeInactiveTokens фильтрует по last_used_at — нативному полю Sanctum на personal_access_tokens (читается через relation, без raw SQL); отдельный индекс не заводится — объём таблицы (десятки-сотни пользователей × 1–5 токенов) не требует его при текущих масштабах.
Что кешируется: настройки группы api-tokens — стандартным кешем SettingsStore ядра (канон §6 стандарта). Список токенов и журнал использования — не кешируются: данные низкочастотные, но отзыв токена обязан быть виден мгновенно (кеш создал бы окно, где отозванный токен выглядит активным в UI). Собственных тегов CacheTags нет — на page-cache модуль не влияет. Rate-limit — не «контентный» кеш, а счётчик с TTL окна (60 сек), отдельная инвалидация не нужна (протухает сам).
Безопасность
Filament — FormRequest на выпуск токена (whitelist abilities из реестра permissions включённых модулей, см. «Модель данных»; abilities не может быть пустым массивом); API /api/v1/admin/api-tokens/* — авторизация по permissions; Sanctum-middleware — только чтение метаданных для журнала, не тела запроса. Rate-limit per-токен применяется поверх общего лимита API и не обходится параллельными запросами (атомарный инкремент, не read-then-write). Секретов в открытом виде не хранит — Sanctum хеширует токен, plaintext доступен только в ответе на выпуск.
Векторы атак и защита:
| Вектор | Защита |
|---|---|
| Подбор токена (brute-force) | Токен Sanctum — случайная строка достаточной энтропии; rate-limit на границе аутентификации (ядро) не даёт перебирать значения |
| Утечка через логи/URL | Токен передаётся только в заголовке Authorization, никогда в query-string; журнал использования хранит route/ip/used_at, не значение токена; логирующие middleware обязаны редактировать Authorization |
| Replay (украденный токен работает, пока не отозван) | Короткий дефолтный TTL (90 дней), уведомление о новом IP, ручной и массовый отзыв |
| Timing-атака на сравнение хеша | Сравнение — штатный hash_equals Sanctum (константное время); модуль не реализует собственного сравнения токенов |
Права: api-tokens.view, api-tokens.manage, api-tokens.manage.own.
Матрица ролей:
| Роль | Просмотр своих | Просмотр чужих | Выпуск | Отзыв (свой/чужой) | Массовый отзыв |
|---|---|---|---|---|---|
Пользователь (api-tokens.manage.own) | ✅ | — | ✅ (себе) | ✅ / — | — |
Менеджер/админ (api-tokens.manage) | ✅ | ✅ | ✅ | ✅ / ✅ | ✅ |
| Studio | ✅ | ✅ | ✅ | ✅ / ✅ | ✅ |
UX-требования
Админ (роль api-tokens.manage):
- пустой список токенов в системе — заглушка с подсказкой «Токены ещё не выпускались» и кнопкой выпуска;
- массовые действия на списке: отзыв нескольких выбранных токенов, отзыв всех токенов пользователя разом (например при компрометации аккаунта) — отдельная кнопка на карточке пользователя, не только на списке токенов;
- ошибки на человеческом языке: «Токен уже отозван», «Достигнут лимит активных токенов () — отзовите неиспользуемый», а не сырое исключение;
- отзыв токена — необратимая операция: подтверждающий диалог с явным текстом («Токен перестанет работать немедленно, отменить нельзя»); массовый отзыв — подтверждение с указанием количества затронутых токенов.
Пользователь, управляющий своими токенами (api-tokens.manage.own):
- plaintext токена показывается один раз, сразу после выпуска, с кнопкой «скопировать» и предупреждением «сохраните сейчас — повторно мы его не покажем»; закрытие диалога без подтверждения копирования — отдельный явный шаг;
- список токенов показывает только
label,abilities,expires_at,last_used_at— без plaintext; - форма выпуска сохраняет введённые
label/abilitiesпри ошибке валидации, не сбрасывает форму.
Крайние случаи и типовые баги
- Токен показан при выпуске, диалог закрыт без копирования → повторный просмотр plaintext невозможен (Sanctum его не хранит) — только выпуск нового токена и отзыв старого;
- Ротация токена → дефолт: выпуск нового + немедленный отзыв старого (без grace-периода), минимизирует риск; опционально
rotation_grace_hours> 0 оставляет старый токен рабочим до истечения окна для бесшовной замены в интеграциях; - Отзыв всех токенов пользователя разом (компрометация аккаунта) → одна операция инвалидирует все токены пользователя, каждый порождает
ApiTokenRevoked, факт фиксируется вcms/audit(если включён) единым действием; - Истёкший токен → 401 с машиночитаемым
code: token_expiredв конверте ошибок (не просто 401 без пояснения), отдельные кодыtoken_revoked/token_missingдля остальных причин отказа аутентификации. Полеcode— обязательная часть конверта ошибок ядра ({message, code, errors}) по ревизии ядра 14.07.2026, п.1; значенияtoken_expired/token_revoked/token_missing— стабильные строки модуля, консистентные с примером ревизии; - Ability токена ссылается на выключенный модуль → permission выключенного модуля не зарегистрирован в Gate → проверка ability возвращает отказ (403), не исключение (500); токен остаётся рабочим для оставшихся abilities;
- Токен не использовался дольше
auto_revoke_inactive_days→RevokeInactiveTokensотзывает его автоматически (тот же эффект, что ручной отзыв), порождаетApiTokenRevoked; владелец не получает отдельного уведомления сверх записи в журнале аудита (см. «Фоновая работа») — интеграция, простаивающая дольше срока, должна перевыпустить токен;auto_revoke_inactive_days = 0выключает поведение полностью; - Гонка: параллельные запросы с одним токеном превышают rate-limit → атомарный инкремент в Redis (не read-then-write) не даёт превысить лимит даже при полностью одновременных запросах;
- Двойной клик на «Выпустить токен» → два токена не создаются: Filament блокирует повторный сабмит на UI-уровне, API — по
Idempotency-Key, обязательному наPOST /api/v1/admin/api-tokens(см. «API») по ревизии ядра 14.07.2026, п.8 — идемпотентность явно распространена на выпуск токенов/ключей; PruneExpiredTokensпадает на середине → идемпотентна: удаление чанками поexpires_at < now(), повторный прогон просто не находит уже удалённые строки, частичное выполнение не портит состояние;cms/notifications-busвыключен → уведомление о новом IP не уходит:NotificationDispatchработает в log-fallback (факт остаётся в логе модуля, владелец токена узнаёт только из журнала использования, не из письма);- Пустой список токенов у нового пользователя → не ошибка, штатное пустое состояние с подсказкой (см. «UX-требования»);
abilities[]пуст при выпуске → отклоняется 422: токен без единой ability — «живой», но бесполезный и вводящий в заблуждение credential, FormRequest требует минимум одну ability;- Изменение
default_rate_limitв настройках → не влияет на уже выпущенные токены:rate_limitфиксируется вcms_api_token_metaна момент выпуска, новый дефолт применяется только к новым токенам (задокументированное поведение, не баг).
Донорский код
| Что взять | Путь |
|---|---|
| Управление токенами, журнал использования | freelance/project/src/app/ (Sanctum-обвязка) |
Легаси-импорт (§16 стандарта): не применимо. У модуля нет исторических данных для миграции — Sanctum-токены выпускаются заново на новой платформе, старые токены донора не переносятся по соображениям безопасности (чужой plaintext недоступен, переносить хеш смысла нет: он привязан к алгоритму и personal_access_tokens целевой системы). cms:api-tokens:import-legacy не реализуется; при переезде клиента donor-пользователи просто выпускают токены заново через Filament.
Тесты и приёмка
- [ ] Контрактные тесты: отозванный токен немедленно теряет доступ;
- [ ] health-чек проверяет наличие просроченных неотозванных токенов (предупреждение);
- [ ] деградация при выключении — Sanctum работает штатно, без UI-управления;
- [ ] rate-limit per-токен не даёт превысить лимит даже при параллельных запросах;
- [ ] нет N+1 при выводе списка токенов с последним использованием;
- [ ] журнал использования не хранится бессрочно — очищается по retention;
- [ ] plaintext токена возвращается только в ответе на выпуск, не логируется и не хранится в БД — повторный просмотр невозможен;
- [ ] ротация токена: при
rotation_grace_hours = 0старый токен недействителен мгновенно, при> 0— до истечения grace-периода; - [ ] массовый отзыв всех токенов пользователя инвалидирует их одной операцией;
- [ ] истёкший/отозванный/отсутствующий токен возвращают разные значения
code(token_expired/token_revoked/token_missing) при одинаковом HTTP 401, конверт соответствует ревизии ядра 14.07.2026; - [ ] ability токена на выключенный модуль не роняет проверку (403, не 500);
- [ ] параллельные запросы с одним токеном не превышают rate-limit (тест на гонку, атомарный инкремент);
- [ ] повторный выпуск токена с тем же
Idempotency-Keyне создаёт дубликат; - [ ]
PruneExpiredTokensидемпотентна при повторном прогоне после сбоя на середине чанка; - [ ] выключенный
cms/notifications-busне роняет выпуск/использование токена — уведомление уходит в log-fallback; - [ ] выпуск сверх
max_active_per_userотклоняется понятной ошибкой, не 500; - [ ]
abilities[]пустым массивом отклоняется на этапе валидации (422); - [ ]
abilitiesпри выпуске принимает только permissions из реестра включённых модулей (whitelist динамический, не хардкод) — попытка указать permission отсутствующего в реестре модуля отклоняется 422; - [ ] ответ на список токенов содержит
last_ip, значение совпадает с последней строкойcms_api_token_usage_logдля того же токена (нет рассинхрона денормализации); - [ ]
RevokeInactiveTokensотзывает токен, не использовавшийся дольшеauto_revoke_inactive_days, и не трогает активные;auto_revoke_inactive_days = 0— джоба не отзывает ничего (тест на оба режима); - [ ] контрактный набор
cms-testingзелёный, пакет протестирован в testbench-изоляции; - [ ] feature-тест на каждый роут API; тестовая БД только
api-tokens_test,migrate:fresh/refresh/resetзапрещены.