Попробовать API
</>
API-ключи, audit-логи и OAuth 2.0: безопасность как часть UX
nadezhnostРазбор
16 мин

API-ключи, audit-логи и OAuth 2.0: безопасность как часть UX

Безопасность в API обычно подаётся как список требований: «храните ключи в env, не коммитьте, не передавайте в URL». Правильно, но не работает — разработчик читает и забывает через два дня. Мы зашли с другой стороны: сделали так, чтобы безопасный сценарий был ещё и самым удобным. Разбираю три механизма, которые реально работают в проде — хранение ключей, audit-лог и выбор между OAuth и API-key.

Коротко

  • API-ключ — это бессрочный пароль без 2FA и без попыточного лимита: кто его получил, делает всё в рамках тарифа. Половину утечек платформа закрывает на своей стороне — только header (не query), bcrypt-хеш вместо ключа, last4 в кабинете, scopes на уровне ключа, ротация через revoked_at.
  • Audit-лог в кабинете — не галочка для compliance, а инструмент самообслуживания интегратора: request_id, коды, длительности, webhook delivery. В BOTIX пишется через Redis-очередь, overhead на запрос — меньше 1мс, retention 7 дней.
  • OAuth 2.0 vs API-key — не «новое против старого», а ответ на вопрос «кто действует от чьего имени»: свой бэкенд → API-ключ, стороннее приложение → OAuth 2.0 (PKCE, state, отзыв установки в один клик).

Первый аудит безопасности прошёл через два месяца после v1.0. Три замечания: ключ можно было передать через query-string ?api_key=... (попал в nginx access.log в открытом виде); audit-лог писался в основную БД синхронно, +30–50мс к ответу; ротация ключей без даунтайма не была документирована — клиенты перевыпускали ключ, ловили 401, тушили пожар. Все три закрыли в v1.1. Параллельно поняли: security в SaaS-API нельзя продавать отдельно от удобства. Если безопасный путь сложнее небезопасного — выбирают небезопасный.

Хранение API-ключей: что может закрыть платформа

API-ключ — это аналог пароля, написанного в открытом виде. У него нет ни срока действия по умолчанию, ни второго фактора, ни попыточного лимита. Любой, кто получил ключ, делает всё, что доступно тарифу проекта: читает контакты, шлёт сообщения, меняет сценарии, удаляет данные. Компрометация одного ключа — это не «инцидент в одной фиче», а потенциально полный slave-доступ к проекту на платформе.

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

  1. Ключ в репозитории$key = 'btx_live_XXXX…' прямо в конфиге, потом репозиторий уезжает в open-source или утекает через бэкап. Секрет-сканеры (GitHub Secret Scanning) ловят паттерн btx_live_… и шлют уведомление владельцу, но между публикацией и отзывом проходят часы.
  2. Ключ в .env без шифрования.env лежит на диске в plaintext; RCE, бэкап-копия или увольнённый админ читают все ключи разом. .env — приемлемое место для *переменной*, но не для *хранения*.
  3. Один ключ на всю команду — master-ключ в Slack, при увольнениях не ротируется; бывший сотрудник может вернуться и навредить.
  4. Ключ в логахerror_log(print_r($headers, true)), и в ELK/Loki/Datadog лежит полный Authorization: Bearer btx_live_….
  5. Ключ в URL?api_key=… размазывается по access.log, прокси и Referer.
  6. Ключ без ротации — выпущен два года назад, пережил два рефакторинга и одно «подозрение, но не точно».
  7. Ключ с избыточными правами — сервис делает только POST /messages, а ключ умеет всё.

Платформа закрывает половину этого на своей стороне. Ключ принимается только в заголовке Authorization: Bearer XXXXXXXX. Query-string запрещён жёстко в app/Middleware/PublicApiAuth.php:

php
public function handle(Request $request, Closure $next): Response
{
    // Категорически не принимаем ключ через query
    if ($request->query('api_key')) {
        return $this->error('API_KEY_IN_QUERY_FORBIDDEN', 400, [
            'reason' => 'API key must be passed in Authorization header, '
                      . 'not in URL — URLs leak through logs and Referer.',
            'docs' => 'https://developers.botix.pro/best-practices',
        ]);
    }

    $header = $request->header('Authorization');
    if (!$header || !str_starts_with($header, 'Bearer ')) {
        return $this->error('UNAUTHORIZED', 401);
    }

    $token = substr($header, 7);
    $key = ApiKey::findActiveByToken($token);

    if (!$key || $key->revoked_at !== null) {
        return $this->error('INVALID_API_KEY', 401);
    }

    $request->attributes->set('api_key_id', $key->id);
    $request->attributes->set('project_id', $key->project_id);
    $request->attributes->set('scopes', json_decode($key->scopes, true));

    return $next($request);
}

В кабинете на app.botix.pro/developers ключ показывается один раз — в момент генерации, с пометкой «скопируйте сейчас, больше не покажется». После этого видны только последние 4 символа: btx_live_…a4f2. В базе хранится bcrypt-хеш ключа, не сам ключ. Если БД утечёт — восстановить рабочие ключи невозможно. Ключ потерян — кнопка «Перевыпустить» рядом, новая пара за клик.

Scopes на уровне ключа. По умолчанию ключ может всё в рамках тарифа. На практике сервис, отправляющий сообщения, не нуждается в правах удалять контакты. Поле scopes — JSON в btx_api_keys и btx_oauth_access_tokens. При создании ключа указывается список scope: messages:write, contacts:read, analytics:read. Middleware проверяет соответствие при каждом запросе:

php
public function assertScope(string $required): void
{
    $scopes = $this->request->attributes->get('scopes', []);
    if (!in_array($required, $scopes) && !in_array('*', $scopes)) {
        throw new ApiException('INSUFFICIENT_SCOPE', 403, [
            'required' => $required,
            'granted' => $scopes,
        ]);
    }
}

На каждый изолированный кусок логики — отдельный ключ с минимальным scope. Если ключ скомпрометирован — ущерб ограничен POST /messages, а не «весь проект».

Ротация без даунтайма. Поле revoked_at в btx_api_keys. Поток: создать новый ключ, прокинуть в код, проверить работу, отозвать старый через revoked_at = NOW(). Все три шага независимые. Никаких grace-period'ов: пока старый не отозван, оба работают; в момент отзыва старый перестаёт. Свитч в коде — атомарный, отзыв старого — отложенный.

Нормальная схема на стороне интегратора закрывает вторую половину: источник правды — secret manager (Vault, AWS Secrets Manager, Yandex Lockbox, в простом случае pass поверх gpg), при деплое ключ инжектится в ENV (docker run -e BOTIX_API_KEY=... или Kubernetes Secret), SDK читает его из ENV и падает на старте с понятной ошибкой, если переменной нет. Примеры инициализации на PHP/Python/Node, читающие из ENV, — на developers.botix.pro/best-practices. Открытые SDK BOTIX маскируют значение Authorization перед логированием автоматически (Bearer ***).

Чек-лист для код-ревью, каждая проверка на минуту: .env в .gitignore; в исходниках нет строк с префиксом btx_live_; конфиг читает ключ через getenv(); SDK инициализируется один раз на старте процесса, не на каждом запросе; в логах headers фильтруются; URL не содержит ключа; на каждый сервис — отдельный ключ, имя сервиса в поле name таблицы btx_api_keys. Ниже этого уровня гигиены работать с публичным API в проде нельзя.

Audit-лог как UX-feature

Журнал API-запросов нужен не для compliance, а интегратору, чтобы самому отлаживать код, не дёргая поддержку. Сцена всегда одинаковая: команда встраивает API, что-то не срослось — контакт не создался, webhook не пришёл; открывают свои логи, видят, что запрос отправлен; дальше нужно понять — дошёл ли до API, что ответил сервер, и если ошибка, то какая. Без публичного журнала единственный путь — тикет «у нас что-то не работает, посмотрите со своей стороны». Один такой запрос растягивает диагностику с пятнадцати секунд (взгляд в журнал) до полудня (тикет, переписка, эскалация), и всё это время на стороне интегратора стоит прод.

Журнал на app.botix.pro/developers, доступ — только владельцу проекта, не агентам и не операторам: он содержит чувствительные для безопасности метаданные. Что в нём:

  • Время запроса с миллисекундами в UTC
  • HTTP-метод, путь, query-параметры (без секретов)
  • Код ответа, размер ответа, длительность обработки на стороне сервера
  • Уникальный request_id — тот же, что в заголовке X-Request-Id у клиента
  • IP отправителя, last4 API-ключа (без самого ключа)
  • Версия API, Idempotency-Key если был

Этого набора хватает на 90% задач отладки. Интегратор пишет «не создался контакт, вот UUID» — поставщик одной командой находит запрос и одной строкой отвечает, какой был код и почему.

Чем это отличается от server-side логов. Серверный лог — это лог инфраструктуры: запросы всех клиентов, внутренние вызовы, периодические задачи, пересечение данных разных тенантов. Журнал в кабинете — вид, отфильтрованный по project_id, с удалёнными чувствительными полями и удобной фильтрацией. В BOTIX доступны фильтры по HTTP-методу, по статусу (2xx, 4xx, 5xx по отдельности), по пути с подстановкой через звёздочку и поиск по request_id.

Что НЕ показываем — полные тела запросов и ответов. Это типичный источник утечек ПД через интерфейс. Тела хранятся только во внутренних логах, доступ — только через SA-консоль и только по письменному запросу владельца проекта.

Webhook delivery — отдельная вкладка. Когда endpoint не получает событий, причин несколько: платформа не сгенерировала событие; сгенерировала, но не положила в очередь; положила, но получила timeout; получила 5xx и поставила в retry; вернула 2xx, но интегратор перепутал тело. Для входящего журнала места этим случаям нет — это исходящая доставка. Поэтому вынесена отдельно; на каждую попытку видны: event-type, endpoint, HTTP-код ответа интегратора, длительность, тело ответа (первые 4 KB, чтобы прочитать сообщение об ошибке), номер попытки, время следующего retry. Интегратор видит, что вернул 500 на седьмое событие подряд, и понимает — ошибка на его стороне, без тикета «вы не присылаете webhook'и».

Журнал как сигнал, а не только отладка. Над журналом — KPI-блок с четырьмя числами: запросов за 24 часа, доля 2xx, 4xx, 5xx. Это базовая обсервабилити, которую интегратор раньше строил у себя в Datadog или Grafana; если поставщик её отдаёт — с интегратора снимается отдельная задача.

Что сломалось. Audit-лог писал в основную БД синхронно — 30–50мс overhead. Переделали через очередь:

php
// PublicApiAuth::handle, after response
public function terminate(Request $request, Response $response): void
{
    $this->redis->rPush('api_log_queue', json_encode([
        'request_id' => $request->attributes->get('request_id'),
        'project_id' => $request->attributes->get('project_id'),
        'api_key_id' => $request->attributes->get('api_key_id'),
        'method' => $request->getMethod(),
        'path' => $request->getPathInfo(),
        'status_code' => $response->getStatusCode(),
        'duration_ms' => round((microtime(true) - LARAVEL_START) * 1000),
        'ip' => $request->ip(),
        'created_at' => date('c'),
    ]));
}

Отдельный воркер php artisan api-log:drain каждые 200мс читает из очереди и батчит INSERT'ы. Overhead на запрос — меньше 1мс. В кабинете запись появляется с задержкой 1–2 секунды. Retention — 7 дней.

Retention и compliance. Для SOC 2, ISO 27001, 152-ФЗ, GDPR журнал на 7 дней не годится — энтерпрайз требует минимум 90 дней, потому что инцидент может всплыть через два месяца. Тема упирается в стоимость хранения: при 10 RPS на проект 7 дней — это 5–6 миллионов записей, 90 дней — 70–80 миллионов. Индустриальное решение типовое: горячий журнал на 7–30 дней в основной БД с быстрой фильтрацией плюс архив на нужный срок в холодном хранилище (S3) по запросу. На текущей фазе BOTIX держит только горячий участок на 7 дней; когда появится первый клиент с требованием compliance-retention — под него включим архивный режим, пометка появится на developers.botix.pro/best-practices. Строить холодное хранилище до появления такого клиента смысла нет. Отдельный нюанс — выгрузка архива содержит ПД (как минимум IP), поэтому кнопки «Скачать журнал за год» не будет: нужен контролируемый flow с подтверждением владельца, записью подтверждения в audit-trail и одноразовым скачиванием. Как этот же контракт ошибок и лимитов выглядит со стороны наблюдаемости — в разборе Observability API: контракт ошибок, лимиты, журнал; чек-лист идемпотентности и rate-limit рядом с журналом — в Idempotency, rate-limit и audit-лог.

OAuth 2.0 vs API-key: где какой использовать

Это не дилемма «современное против устаревшего». Это два инструмента для двух сценариев, и подмена одного другим почти всегда ведёт к утечке доступа. Главный вопрос — «кто действует от чьего имени». От ответа зависит выбор механизма.

Сценарий 1. Компания купила SaaS, у неё свой бэкенд, она хочет, чтобы её бэкенд общался с API. Бэкенд принадлежит ей, ключ принадлежит ей, действия выполняются от её имени. Это интеграция собственного приложения. API-ключ — правильный инструмент.

Сценарий 2. Появилось третье приложение — сервис-партнёр. Он хочет работать с данными клиента, который пользуется SaaS. Клиент даёт разрешение. Сервис не знает API-ключ клиента и не должен его знать. Это интеграция стороннего приложения. OAuth 2.0 — правильный инструмент.

Когда сценарии путают, появляются две типичные ошибки. Первая — клиент копирует production-ключ и вставляет в форму «введите API-ключ» в стороннем сервисе. С этого момента сервис имеет полный доступ; если он нечестный — клиент не может ограничить, потому что у API-ключа нет области действия; если честный — он хранит ключ в своей БД, и утечёт БД, утечёт и ключ. Вторая — сервис-партнёр просит API-ключ как часть онбординга. Работает, пока клиент пользуется сервисом; когда он его удаляет, ключ остаётся в чужой БД и продолжает работать. Чтобы реально отозвать, нужно зайти в кабинет основного SaaS и удалить ключ вручную — чего на практике никто не делает. OAuth решает обе: сторонний сервис никогда не получает долгоживущий ключ, а клиент отзывает установку в один клик, и доступ исчезает немедленно.

Почему API-ключ остаётся правильным для собственного бэкенда. OAuth — это не «безопаснее», а другая модель доверия: короткоживущие токены, отзыв, scope. Всё это нужно, когда доступом владеет третья сторона. У собственного бэкенда нет проблемы «кто-то сольёт мой ключ» — он сам владелец. Добавление OAuth-flow здесь — только усложнение: вместо одного заголовка Authorization: Bearer XXXXXXXX появляется танец из получения access token, обновления через refresh token, обработки 401 на истечении и повторного запроса. Индустриальный паттерн: API-ключи — для собственных интеграций, OAuth — для third-party, и в зрелом API оба сосуществуют.

В BOTIX оба механизма на одних и тех же эндпоинтах api.botix.pro/public/v1/*. Различия только в заголовках:

  • API-ключ: Authorization: Bearer XXXXXXXX
  • OAuth access token: Authorization: Bearer ya29.a0... + X-OAuth-App-Id: app_01HQRX...

Эндпоинты обрабатывают оба одинаково. Различие в учёте: запросы по OAuth логируются с привязкой к oauth_app_id (отдельная колонка в btx_api_log), и клиент видит, какое именно приложение делало запрос.

Минимальный набор для Authorization Code flow, без срезок:

  • PKCE с code_verifier и code_challenge (закрывает authorization code interception)
  • Параметр state случайный и проверяемый (без него — CSRF на callback)
  • Авторизационный код живёт 5–10 минут, используется одноразово
  • Access token — 1 час. Refresh token — 30–90 дней, ротируется при каждом обмене (старый инвалидируется)
  • Владельцу кабинета доступен список активных installations с отзывом в один клик

Каждая срезка (нет PKCE, нет state, длинный expiry) превращает OAuth в дырявое решето, которое формально называется OAuth, а фактически даёт меньше защиты, чем добротно сделанный API-ключ. В BOTIX все пять пунктов реализованы. Документация — developers.botix.pro/oauth. Регистрация приложения — app.botix.pro/settings/oauth-apps (для разработчика). Подключённые приложения и их отзыв — app.botix.pro/settings/connected-apps (для пользователя). Две страницы намеренно разнесены: разработчик регистрирует, пользователь даёт согласие.

Scopes на согласии. API-ключ всегда даёт полный доступ; у OAuth-токена есть scope — заявленный набор разрешений, который пользователь видит на странице согласия и решает, разрешать или нет. Это принцип наименьших привилегий плюс прозрачность: если приложение умеет только рассылать сообщения, оно не должно иметь возможности удалять контакты, а пользователь видит объём запроса и может отказаться, если он непропорционален задаче. Список scope в BOTIX небольшой и сознательно не дробится мелко — каждый scope соответствует понятной группе действий; удаление scope из стабильного списка — breaking change. Тот же принцип минимальных прав со стороны выдачи ключей — в разборе про HMAC-подпись и идемпотентность webhook и очередь доставки webhook.

Когда переходить. Одно приложение, интегрируемое с BOTIX, — API-ключ навсегда, OAuth не нужен. Маркетплейс приложений — OAuth с самого начала, компромиссов нет: пускать сторонние приложения по API-ключам значит в первый серьёзный инцидент уничтожить репутацию. Граница между случаями — момент появления первого стороннего приложения. До него OAuth — преждевременная оптимизация, в этот момент — необходимость.

Где остался компромисс

Automatic key rotation по расписанию — всё ещё ручная операция. Поддерживаем «два активных ключа на проект», но cron, который раз в полгода создаёт новый ключ и помечает старый к отзыву через 30 дней, не сделали. Клиент сам помнит и нажимает «Перевыпустить». В roadmap на v1.2: флаг auto_rotation_enabled в btx_api_keys, cron раз в сутки проверяет ключи с created_at < NOW() - INTERVAL 180 DAY, создаёт новый, шлёт email владельцу, через 30 дней — revoked_at = NOW() для старого. Откладывали, потому что без массовых клиентов не выглядит проблемой.