0049. Безопасность персональных токенов, входа и журнал identity-событий
Статус: accepted. Дата: 2026-10-01.
Контекст
Персональные токены доступа (PAT) и самостоятельная регистрация (f2e980d) вошли без разграничения видов учётных данных. Проверка на работающем сервере подтвердила:
- PAT создавал новые PAT, менял пароль, нёс флаг
administratorи жил бессрочно; дочерние токены переживали отзыв родителя, смена пароля и административный сброс не отзывали PAT; - изменения identity не попадали ни в какой журнал;
- анонимная регистрация была не ограничена и могла исчерпать общий предел 1000 учётных записей; консоль показывала «Регистрация» при выключенном флаге;
- блокировка «5 ошибок → 15 минут» позволяла любому, кто знает имя, удерживать владельца вне системы; одного общего password gate (2 слота, очередь 16) хватало, чтобы анонимный поток блокировал и административную работу с паролями.
Решение
Вид учётных данных в Principal. Обязательное поле credential: session | personal-token | service-key | file-key выставляет каждый источник: сессия и PAT — PostgresIdentity.resolve, управляемый ключ — managedPrincipal, файл ключей — parseKeys и дополнительно request-security. Политики больше не выводят вид из префикса ID. GET /api/v1/auth/me возвращает credential и tokenScope (аддитивно).
PAT.
- Создание, список и отзыв собственных PAT и смена пароля требуют
credential=session(политикаaccount-session). Отказ PAT/ключа журналируется (denied,session_required). - Управление учётными записями, группами, чужими PAT и журналом требует администратора, но никогда не PAT и не managed key. PAT не наследует
administratorпри построении principal. - Scope
read | read-write; по умолчаниюread-writeради совместимости wire-контракта, консоль по умолчанию предлагаетread. Уreadпри построении principal удаляются все write-grants; дополнительноrequest-securityотклоняет любой метод кроме GET/HEAD (кроме безопасногоlogout) с 403. - Срок обязателен: по умолчанию 90 дней, максимум 365, прошедшие/текущие/некорректные даты (только RFC 3339) отклоняются; превышение максимума до часа считается расхождением часов клиента и усекается. Строки без срока от старого кода ограничиваются
created_at + 365 days. - Список: активные токены сортируются первыми, лимит 100 при максимуме 50 активных, поэтому история отзывов не скрывает действующие. Отзыв идемпотентен. Мёртвые строки старше 30 дней удаляются при создании нового PAT.
- Смена своего пароля и административный сброс в той же транзакции удаляют сессии и отзывают все PAT пользователя.
- Администратор:
GET /api/v1/users/{id}/tokens,DELETE /api/v1/users/{id}/tokens/{tokenId}(API и SDKadministration.users.tokens/revokeToken). - Фоновые задания пользователя (worker) выполняются как автоматизация учётной записи: repository grants без
administrator.
Журнал arkvory_security_audit (миграция 23). Append-only: триггер запрещает UPDATE и DELETE; удаление разрешено только ограниченной retention-партии с локальным флагом транзакции. Запись: время БД, actor, вид учётных данных, действие, цель, исход success|failure|denied, машинный код, адрес клиента, небольшие details без секретов. Успешные изменения пишутся в той же транзакции, что и изменение; неудачный вход и неверный текущий пароль фиксируются вместе с обновлением backoff. Отклонённые throttling-запросы не журналируются, чтобы поток не превращался в рост таблицы. Retention: 365 дней и не более 1 000 000 строк, партия 5000 раз в минуту на writer. GET /api/v1/security/audit?after&limit — только администратор, новые записи первыми, keyset-курсор по id.
Регистрация. features.selfRegistration в capabilities и публичный GET /api/v1/auth/options (консоль решает до входа). Кнопка регистрации скрыта по умолчанию и показывается только при selfRegistration=true; ошибка запроса оставляет её скрытой. Самостоятельная регистрация прекращается при 900 учётных записях: последние 100 мест остаются администраторам.
Throttling и backoff.
- In-memory token bucket по адресу клиента до хеширования: вход 10 попыток, пополнение 1 за 15 с, успешный вход возвращает токен; регистрация 3 на адрес (1 за 20 мин) и 20 на процесс (1 за 3 мин). Карта ограничена 10 000 адресов; сначала вытесняются полные корзины, затем наименее недавние. IPv6 группируется по /64, IPv4-mapped разворачивается. Отказ —
429 rate_limitedсRetry-After. - За reverse proxy адрес берётся из
X-Forwarded-Forтолько дляARKVORY_TRUSTED_PROXIES(IP/CIDR). - Вместо жёсткой блокировки — per-account leaky bucket в PostgreSQL по времени БД: каждая проверенная ошибка пароля +1, долг убывает на 1 за 6 с, backoff начинается выше 20 и удваивается от 1 с до предела 120 с. Во время backoff попытка отклоняется до хеширования, долг не растёт, даже верный пароль ждёт. Скорость одного адреса (4/мин, или 8/мин при двух процессах) ниже скорости убывания (10/мин), поэтому один клиент не может удерживать backoff; распределённая атака вызывает задержки не дольше двух минут за волну, а каждая волна требует новых ошибок.
- Анонимный password gate: 2 слота, очередь 16, на адрес 1 активный и 4 ожидающих. Отдельный gate (1 слот, очередь 8, 10 с) для смены своего пароля и административного create/reset не конкурирует с анонимными входами.
Альтернативы
- Блокировка по паре (адрес, account) в памяти: обходится распределённо и не переживает рестарт; оставлена только защита per-address.
- Отдельный нежурналируемый счётчик неизвестных имён: усложняет модель, перечисление имён и так ограничено throttling.
- Требовать
scopeв каждом запросе: ломает существующих клиентов SDK; выбран явный default и read по умолчанию в консоли. - Аудит вне транзакции после фиксации: мог бы потерять событие или сообщить о незафиксированном изменении.
Последствия
Миграция 23 только расширяет схему (rolling update): старый код может записать PAT без срока или 15-минутную блокировку, новый код ограничивает и учитывает их. Номер 22 зарезервирован под promotions, разрабатываемые параллельно. Throttling — состояние процесса: перезапуск сбрасывает корзины, а per-account backoff остаётся в БД. Перечисление существования имени через 429 возможно только после распределённого перебора этого имени. Журнал при длительном флуде вытесняет старые записи по пределу строк — для production экспортируйте его во внешний SIEM. Отключение учётной записи PAT не отзывает: они перестают работать, пока учётная запись выключена.
Проверка
Unit: tests/identity-service.test.mjs, tests/auth-throttle.test.mjs, tests/identity-contracts.test.mjs. PostgreSQL/HTTP: tests/integration/token-security.test.mjs, tests/integration/auth-throttle.test.mjs, обновлённые identity, review-regressions, api-contract.