Попробовать API
</>
Multi-tenant API: OAuth, scopes и изоляция project_id
integraciyaИнструкция
19 мин

Multi-tenant API: OAuth, scopes и изоляция project_id

TL;DR: API-ключ хватает одному интегратору. Агентству, которое держит 100 проектов клиентов через один SDK-слой, нужен другой контракт — OAuth 2.0 для делегированного доступа, scopes в токене, изоляция `project_id` на всех уровнях и обособленный аудит на каждого tenant. Разбираю, как публичный API BOTIX перестраивается из «инструмента для одного клиента» в «платформу для интеграторов»: реальный контур аутентификации (`Bearer btx_live_…`, `GET /me`, коды ошибок), фрагменты `openapi.yaml`, политика scope, изоляция данных, чек-лист готовности и честный список того, что ещё не поехало.

Коротко

  • Один API-ключ решает задачу одного интегратора со своим бэкендом. Агентству на десятки проектов нужен другой контракт: OAuth 2.0 для делегированного доступа, scopes в токене, изоляция project_id на всех слоях и аудит по каждому tenant.
  • Изоляция — не одна проверка, а три слоя: WHERE project_id = ? в каждом SQL-запросе, Auth::assertProjectAccess() в начале каждого контроллера (чужой проект отдаёт 404, не 403, чтобы не утекало существование объектов) и один SDK-инстанс на проект вместо глобального.
  • OAuth и API-ключ решают разные задачи: ключ — для своего бэкенда, OAuth — для стороннего приложения, которому пользователь дал доступ. Путать = утечка. Partner API и white-label-домены — отдельный слой, часть которого пока в roadmap'е.

Три задачи, которые звучат по-разному, решаются одним архитектурным выбором: «считаем ли мы API инструментом для одного клиента или платформой для интеграторов». От ответа зависят схема БД, формат токенов, политика scope и набор эндпоинтов. Разберём по трём слоям: multi-tenant-контур, выбор между OAuth и ключом, и то, что должно быть в API, чтобы открыться партнёрам.

Сценарий, в котором API перестаёт быть просто API

Платформа продаёт чат-ботов. Прямые клиенты — салон красоты, автосервис, барбершоп. Каждый — отдельный проект, своя база контактов, свои сценарии, свой тариф.

В какой-то момент приходит первое агентство, ведущее 30 клиентов. Каждый — отдельный проект. Агентство хочет: один SDK-слой поверх 30 проектов, биллинг клиентов по своей модели, регистрацию через API, audit-логи по каждому проекту, изоляцию данных между ними.

Если отдать агентству 30 API-ключей — оно увязнет в управлении 30 секретами. Один скомпрометированный ключ = ручное расследование. Если клиент уйдёт — забыть отозвать почти неизбежно. Это место, где API перестаёт быть просто API. Большинство ошибок multi-tenant-интеграции лежит не в коде, а в неявных предположениях: «допустим, project_id один на запрос», «допустим, токен можно положить в общий конфиг», «допустим, лимит у всех одинаковый». Каждое однажды оказывается неверным — и тогда клиент A видит данные клиента B либо вся интеграция упирается в лимит самого слабого тарифа из ста.

Реальный контур: чем аутентифицируется публичный API

Прежде чем городить OAuth, стоит понять, что уже есть. Публичный API BOTIX (https://api.botix.pro/public/v1/*) авторизуется Bearer-ключом:

Authorization: Bearer XXXXXXXX…   (btx_live_ + 40 символов)

Каждый ключ привязан к одному project_id и несёт список scopes. Проверить контекст ключа, не делая ни одной мутации, можно служебным эндпоинтом GET /me:

GET /public/v1/me
Authorization: Bearer XXXXXXXX…

200 OK
{
  "success": true,
  "data": {
    "project_id": 4210,
    "scopes": ["contacts:read", "messages:send"],
    "plan_key": "business",
    "rate_limit": { "per_minute": 600, "remaining": 597 }
  }
}

Ключ хранится не в открытом виде: в btx_api_keys лежит bcrypt-хеш (cost 12), сверка — password_verify + hash_equals (защита от timing-атак), а для отображения и поиска отдельно хранится префикс из 12 символов. Полный ключ показывается один раз при создании — потерял, значит перевыпускай, восстановить нельзя по дизайну.

Ответы всегда в конверте {success, data, meta} либо {success: false, error: {code, message, details}}. Коды ошибок стабильны и стоит знать наизусть: MISSING_API_KEY / INVALID_API_KEY / KEY_REVOKED (401), INSUFFICIENT_SCOPE / API_NOT_AVAILABLE_ON_PLAN / TRIAL_READ_ONLY (403), RATE_LIMIT_EXCEEDED (429). Это тот фундамент, поверх которого достраиваются OAuth и партнёрский слой.

Границы доверия: три зоны

В multi-tenant-интеграции три зоны: интегратор, платформа, клиент платформы:

   ┌──────────────┐        ┌──────────────┐        ┌──────────────┐
   │  Интегратор  │ <----> │  Платформа   │ <----> │   Клиент     │
   │  (агентство) │        │   BOTIX      │        │  платформы   │
   └──────────────┘        └──────────────┘        └──────────────┘
          |                       |                       |
          v                       v                       v
    своя БД + код         project_id, audit          доступ к своим
    + key storage         + rate-limit               данным

Механизмы аутентификации отражают эти границы тремя паттернами:

  • API-ключ на проект — интегратор хранит по ключу на каждого клиента, ключи через кабинет. Прост, предсказуем, но новый клиент = ручной шаг (заходит в кабинет, генерирует ключ, отдаёт).
  • OAuth installation — третья сторона регистрирует у платформы приложение, любой клиент одним кликом «подключает приложение к моему проекту». Стандарт для third-party (GitHub Apps, Stripe Connect).
  • Partner API — отдельный набор прав, позволяющий создавать проекты от имени партнёра. Для крупных интеграторов и реселлеров.

Доступны первый и второй паттерны. Partner API в roadmap'е без публичного релиза. Подробнее про выбор транспорта и polling vs webhook — в разборе Multi-tenant, polling vs webhook и OAuth.

Изоляция project_id: критическая граница

Внутри одного партнёрского аккаунта могут быть проекты конкурирующих компаний. Возможность одного увидеть данные другого — катастрофа. Защита трёхслойная.

На уровне БД: WHERE project_id = ? в каждом запросе. Это закреплено правилом разработки и проверяется через Auth::assertProjectAccess() в каждом контроллере:

php
// Каждый контроллер начинается с этого
public function listContacts(Request $request): Response
{
    $projectId = (int) ($request->getQueryParams()['project_id'] ?? 0)
        ?: Auth::projectId();

    // Без этой строки — утечка данных между проектами
    Auth::assertProjectAccess($projectId);

    $contacts = $this->repo->listByProject($projectId, /* cursor, filters */);
    return new JsonResponse(['success' => true, 'data' => $contacts]);
}

Auth::projectId() берёт из сессии — это fallback. Источник правды — явный project_id в запросе или резолв из токена. Важная деталь: при попытке дотянуться до ресурса чужого проекта API отдаёт 404, а не 403 — так наружу не утекает даже сам факт существования объекта.

На уровне API: project_id явный в каждом запросе или резолвится из токена (Auth::setApiKeyContext() устанавливает контекст без сессии).

На уровне ключей: API-ключ и OAuth installation привязаны к одному project_id (btx_api_keys.project_id, индекс (project_id, status)). Нельзя получить ответ по проекту A, передав ключ проекта B.

Один SDK-инстанс на проект, не глобально

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

python
# Правильно — кэш инстансов
from botix import Client

clients = {
    project_id: Client(api_key=XXXXXXXX(project_id))
    for project_id in active_projects
}

def send_to_project(project_id: int, contact_id: int, text: str):
    client = clients[project_id]
    client.messages.create(contact_id=contact_id, text=text)

Ключи берутся из защищённого хранилища (vault, secret manager) и кэшируются. В момент использования невозможно перепутать project_id с api_key. Если у одного project_id ключ ротируется — пересоздаётся только один инстанс, остальные продолжают работать.

Scopes: минимальный набор привилегий

API-ключ по умолчанию может всё, что доступно тарифу проекта. Но каждому изолированному куску логики стоит выдать отдельный ключ с суженным scope: сервис отправки — messages:send, аналитический воркер — только чтение. Если ключ утечёт, ущерб ограничен его scope. У OAuth-токена scope обязателен: на странице согласия пользователь видит список и решает.

Список scope сознательно не дробится мелко — каждый соответствует понятной группе действий. Реальный набор публичного API:

ScopeЧто разрешает
contacts:readЧтение контактов и их атрибутов
contacts:writeСоздание и обновление контактов
messages:readЧтение истории сообщений
messages:sendОтправка сообщений через канал
scenarios:readЧтение списка сценариев
scenarios:runСинхронный запуск сценария для контакта
chats:readЧтение диалогов
channels:readСписок подключённых каналов проекта
webhooks:readЧтение webhook-подписок
webhooks:writeСоздание и удаление webhook-подписок

Расширение списка возможно, удаление scope из стабильного списка — breaking change. Middleware Public API (app/Middleware/PublicApiAuth.php) при каждом запросе через requireScope('messages:send') проверяет, что эндпоинт входит в scope; несоответствие — 403 INSUFFICIENT_SCOPE. Как раскладывать логику по ключам и ротировать их без даунтайма — в разборе Multi-tenant: изоляция, scopes и ротация ключей.

Ротация ключей и audit-логи

Любой ключ когда-нибудь нужно ротировать: плановый ротейт раз в полгода — гигиена, внеплановый после утечки — обязательная процедура. Архитектура должна допускать ротацию без даунтайма: создать новый ключ, прокинуть в код, проверить, отозвать старый — все три шага независимы.

Помогает разделение состояний ключа: active (можно использовать) и revoked (отказ). В btx_api_keys это поля status и revoked_at. Никаких grace-периодов с двумя ключами: новый работает до отзыва старого. Отдельный хук: при отзыве подписки или переходе на тариф с api_enabled=0 все ключи проекта автоматически становятся revoked.

Финальный слой — журнал btx_api_log: на каждый HTTP-запрос строка с методом, эндпоинтом, статус-кодом, IP, user-agent, response_time_ms и request_id (UUID). Retention — 90 дней, фильтр по ключу и статусу доступен в кабинете. Журнал закрывает три задачи: отладку (где начались ошибки), расследование инцидентов (какие запросы шли с подозрительного ключа) и оптимизацию (что вызывается чаще — может, пора на webhook). Запросы по OAuth логируются с привязкой к oauth_app_id отдельной колонкой — клиент видит, какое именно приложение делало запрос.

Мутирующие эндпоинты (POST /messages, POST /scenarios/{id}/run) поддерживают заголовок Idempotency-Key: результат кешируется в Redis на 24 часа под ключом papi:idem:{apiKeyId}:{key}, при повторе возвращается тот же ответ с заголовком Idempotent-Replayed: 1. Кешируется только успех (200/201), не ошибки доставки — новый календарный день требует нового ключа.

OAuth 2.0 vs API-ключ: два инструмента, не альтернативы

В обсуждениях регулярно появляется ложная альтернатива: «у нас уже есть API-ключ, зачем OAuth» или «OAuth современный, ключи устарели». Обе исходят из того, что они решают одну задачу. На самом деле — разные. Вопрос, с которого всё начинается: «кто действует от чьего имени».

Сценарий 1 — собственный бэкенд. Компания купила SaaS, её бэкенд общается с API. Бэкенд принадлежит ей, ключ принадлежит ей, действия от её имени. API-ключ — правильный инструмент. Добавлять OAuth-flow здесь — усложнение без выгоды: вместо одного заголовка Authorization: Bearer btx_live_… появляется танец из access token, refresh, обработки 401 на истечении.

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

Когда путают: клиент копирует production-ключ в форму «введите API-ключ» в стороннем сервисе. С этого момента сторонний сервис имеет полный доступ. Если его БД сольётся — сольётся и ключ. Если сервис окажется нечестным — клиент не может ограничить доступ, потому что у API-ключа нет «области действия». Вторая ошибка — сервис-партнёр просит ключ на онбординге: клиент удаляет сервис, а ключ остаётся у него в БД и продолжает работать.

Оба механизма доступны на одних эндпоинтах api.botix.pro/public/v1/*. API-ключ — Authorization: Bearer btx_live_…. OAuth — тот же Bearer плюс X-OAuth-App-Id. С точки зрения эндпоинта различие минимально; различие в учёте — в привязке к oauth_app_id в btx_api_log. Индустриальный паттерн: ключи — для собственных интеграций, OAuth — для third-party, у зрелого API сосуществуют оба. Детальный разбор границы — OAuth vs API-key: два инструмента для двух сценариев.

OAuth 2.0: минимальный набор, без срезок

Когда команда впервые делает OAuth, велик соблазн упростить. Не делать PKCE. Не делать state. Не делать короткий expiry. Каждая срезка превращает OAuth в дырявое решето, которое формально OAuth, а фактически даёт меньше защиты, чем добротно реализованный API-ключ.

Минимальный набор для Authorization Code flow:

  • PKCE с code_verifier и code_challenge. Закрывает атаку «authorization code interception».
  • Параметр state, случайный и проверяемый. Без него — CSRF на callback.
  • Авторизационный код живёт 5-10 минут, одноразовый.
  • access_token — около часа. refresh_token — 30-90 дней, ротируется при каждом обмене.
  • Список активных installations с отзывом в один клик в кабинете клиента.

Sequence OAuth-flow:

User       Third-party App         BOTIX
 |              |                    |
 |              | 1. GET /oauth/authorize?client_id=...&redirect_uri=...
 |              |    &response_type=code&state=xyz&code_challenge=...
 |              |                    |
 |              | --2. редирект user --->
 |              |                    |
 | <----3. страница согласия -------|
 |    (login + scope list)          |
 |              |                    |
 | --4. одобрение --------->        |
 |              |                    |
 | <-----5. редирект на redirect_uri?code=AUTH_CODE&state=xyz
 |              |                    |
 |              | 6. POST /oauth/token
 |              |    grant_type=authorization_code
 |              |    code=AUTH_CODE
 |              |    code_verifier=...
 |              |    client_id=..., client_secret=...
 |              |                    |
 |              | <------7. JSON: { access_token, refresh_token, expires_in }
 |              |                    |
 |              | 8. GET /public/v1/contacts
 |              |    Authorization: Bearer <access_token>
 |              |    X-OAuth-App-Id: app_xyz
 |              |                    |
 |              | <-----9. данные проекта ---

Документация — developers.botix.pro/oauth. Регистрация приложения — app.botix.pro/settings/oauth-apps. Подключённые приложения и отзыв — app.botix.pro/settings/connected-apps. Страницы намеренно разнесены: разработчик регистрирует, пользователь даёт согласие.

Фрагмент openapi.yaml: обмен кода на токен

yaml
paths:
  /oauth/token:
    post:
      summary: Обмен authorization code на access_token
      operationId: oauthExchangeToken
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              type: object
              required: [grant_type, client_id, client_secret]
              properties:
                grant_type:
                  type: string
                  enum: [authorization_code, refresh_token]
                code:
                  type: string
                  description: Обязательно при grant_type=authorization_code
                code_verifier:
                  type: string
                  description: PKCE verifier, обязателен при authorization_code
                refresh_token:
                  type: string
                  description: Обязателен при grant_type=refresh_token
                client_id:
                  type: string
                client_secret:
                  type: string
      responses:
        '200':
          description: Успех
          content:
            application/json:
              schema:
                type: object
                required: [access_token, token_type, expires_in, scope]
                properties:
                  access_token:
                    type: string
                  refresh_token:
                    type: string
                    nullable: true
                  token_type:
                    type: string
                    enum: [Bearer]
                  expires_in:
                    type: integer
                    example: 3600
                  scope:
                    type: string
                    example: "messages:send contacts:read"
                  project_id:
                    type: integer
                    description: Привязанный проект

Ротация refresh-token

Часто упускают. Если refresh_token живёт месяц и не ротируется — украденный токен даёт доступ на месяц без шансов обнаружить.

При каждом обмене старый refresh_token инвалидируется, выпускается новый. Если злоумышленник использовал refresh раньше настоящего клиента — тот при следующей попытке получит ошибку, триггерится инцидент. Это дешёвый способ обнаружить утечку без внешнего мониторинга.

Проверка scope на эндпоинте

В middleware Public API при каждом запросе проверяется, что эндпоинт входит в scope токена. Для API-ключа scope тоже есть, но проверка одинаковая:

php
// app/Middleware/OAuthScopeMiddleware.php
public function handle(Request $request, Closure $next, string $requiredScope): Response
{
    $token = $request->getAttribute('oauth_token');

    if ($token === null) {
        // Это API-ключ, не OAuth — scope-проверка идёт в PublicApiAuth
        return $next($request);
    }

    $grantedScopes = explode(' ', $token['scope'] ?? '');

    if (!in_array($requiredScope, $grantedScopes, true)) {
        return new JsonResponse([
            'success' => false,
            'error' => [
                'code' => 'INSUFFICIENT_SCOPE',
                'message' => "Required scope: {$requiredScope}",
                'request_id' => $request->getAttribute('request_id'),
            ]
        ], 403);
    }

    return $next($request);
}

403 INSUFFICIENT_SCOPE — стандартный ответ, виден в audit-логе клиента.

Когда переходить с API-ключа на OAuth

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

White-label: что должно быть для агентства

Теперь третий слой — партнёрский. Один эндпоинт /contacts не делает API платформой. Платформа начинается там, где появляется второй уровень: партнёр, встраивающий API внутрь своего продукта и продающий дальше. У прямого клиента контур простой: один аккаунт, один проект, несколько ключей, один webhook. У партнёра — многоуровневый: сам партнёр сверху, десятки конечных проектов снизу, у каждого свои данные, биллинг, webhook, изоляция.

Минимальный набор возможностей, без которого партнёрская работа не поедет:

  • Партнёрский уровень аутентификации. Отдельный тип ключа (partner key) с правом создавать проекты, перечислять их, получать ключи. Обращение к партнёрским эндпоинтам обычным ключом — 403.
  • Sub-проекты под одним аккаунтом. Иерархия: партнёр сверху, конечные проекты снизу. Каждый sub-проект — со своими данными, webhook, биллингом, изоляцией.
  • OAuth-installations с реселлерскими scope. Приложение, установленное в партнёрский аккаунт, видит все sub-проекты через partner-scope, а не только тот, куда установлено напрямую.
  • Биллинг-события через API. Партнёр биллит конечных клиентов по своей модели. Нужны сырые данные: количество запросов, сообщений, активных контактов по sub-проекту за период. Либо эндпоинт GET /partner/usage, либо webhook в конце расчётного периода.
  • Audit-логи на уровне партнёра. Когда конечный клиент пишет «у нас что-то не работает», партнёр должен сам диагностировать без поставщика.
  • Кастомные домены и брендирование. Сам API кастомного домена не требует. Но если в партнёрском решении есть UI под брендом партнёра — нужны white-label-домены.

Изоляция данных между sub-проектами

Внутри одного партнёрского аккаунта могут быть sub-проекты конкурирующих компаний. Граница жёсткая: на уровне БД — WHERE project_id = ? в каждом запросе; на уровне API — явный project_id и проверка, что он принадлежит партнёрскому аккаунту; на уровне ключей — нельзя получить ответ по одному sub-проекту, передав ключ другого.

Auth::assertProjectAccess($projectId) вызывается в начале каждого эндпоинта, работающего с проектом. Партнёрский уровень добавляет слой: проверяется не только доступ к sub-проекту, но и то, что sub-проект принадлежит партнёрскому аккаунту, выпустившему ключ. Документация для партнёра — отдельная, не подмножество обычной коллекции.

Чек-лист готовности к партнёрской работе

Оценивается за полчаса по короткому списку:

Свойство APIЗачем
Отдельный тип ключа с расширенными правамиПартнёр != конечный клиент
Концепция sub-проектовИерархия партнёр → клиент
WHERE project_id = ? в каждом запросеИзоляция данных
assertProjectAccess() в начале каждого эндпоинтаЗащита от утечки
Отдельные scope для партнёров в OAuthРасширенные права OAuth
Способ получить сырые usage-данные через APIБиллинг партнёра
Аудит-журнал по всем sub-проектамДиагностика партнёром
Отдельная документация и Postman-коллекцияНе подмножество обычного API

Если «нет» начиная с третьего пункта — API не готов к партнёрской работе. Если «да» на все — открывать партнёрам можно без страха инцидента на первой неделе.

Что не делать

Не навешивать партнёрские возможности на обычные эндпоинты через флаги («если ты партнёр, передай ещё этот параметр»). Запутывает семантику. Лучше отдельный префикс /partner/v1/*.

Не делать партнёрский API «по запросу» только избранным по NDA. Работает, пока партнёров три. Когда двадцать — разные обещания, разные SLA, теневая документация. Через полгода — техдолг.

Не ставить высокий минимальный объём для входа. На старте партнёрский слой — маленькие команды, экспериментирующие. Если требовать минимум 10000 USD/мес — маленькие не приходят. А именно из них вырастают крупные.

Что не получилось

Честно про текущее состояние, чтобы не строить иллюзий:

  • Partner API ещё не публичен. В коде есть эндпоинты /partner/v1/*, схема БД с btx_partner_accounts и btx_oauth_installations.partner_id. В первой итерации релиза выпустили только обычный API + OAuth.
  • Rate-limit по уровням иерархии. Лимит на уровне project_id (api_rate_per_minute от 30 до 2000, api_rate_per_day от 1000 до 1000000). Суммарный лимит всех sub-проектов партнёра не ограничен. Двухуровневый rate-limit — открытая задача.
  • White-label-домены через CNAME. В roadmap'е. Пока агентства собирают свой UI поверх нашего API, а кабинет остаётся на app.botix.pro под нашим брендом.
  • OAuth-приложения проходят ручную модерацию. Регистрация создаёт приложение в статусе pending_review. На первых 10-20 приложениях это норма, на сотне — узкое место.

Полезные ссылки

  • Документация (Redoc): https://developers.botix.pro/
  • OpenAPI-спецификация: https://developers.botix.pro/openapi.yaml
  • Ключи проекта (кабинет клиента): app.botix.pro/settings?tab=api-keys
  • OAuth: документация developers.botix.pro/oauth, регистрация app.botix.pro/settings/oauth-apps, подключения app.botix.pro/settings/connected-apps
  • Статус API: https://status.botix.pro/