Перейти к содержанию

Шлюз platform-api

platform-api — серверная часть панели платформы: вход людей, профиль и администрирование tenant'а, документы, уведомления и шлюз /api/v1/services/{service}/…, через который веб-консоль говорит с Control Plane и сервисами вертикальных пакетов от имени вошедшего человека. Статья описывает аутентификацию запросов, шлюз и федерацию identity в IAM, rate limit, работу за прокси с префиксом пути и подключение людей к сервисам.

Замороженный периметр

platform-api входит в профиль compose platform (заморожен, TAI-ADR-0034, TAI-ADR-0039). Исходный код — компонент platform-core (apps/api).

Конфигурация

platform-api и одноразовый platform-migrations получают общий набор переменных (якорь platform-env в compose.yml). Главные из них:

Переменная Значение в compose Назначение
POSTGRES_HOST / _DB / _USER / _PASSWORD platform-db / platform / platform / PLATFORM_POSTGRES_PASSWORD база панели
REDIS_HOST, REDIS_PASSWORD platform-redis rate limit, кэш
KEYCLOAK_URL ${TAIMEN_PUBLIC_URL}/auth публичный адрес Keycloak: issuer и JWKS
KEYCLOAK_REALM platform realm
KEYCLOAK_CLIENT_ID platform-api клиент для password grant; он же ожидаемый aud
KEYCLOAK_CLIENT_SECRET из .env секрет confidential-клиента
APP_BASE_URL ${TAIMEN_PUBLIC_URL}/platform публичная база API
WEB_BASE_URL, CORS_ALLOWED_ORIGINS ${TAIMEN_PUBLIC_URL} origin консоли
TRUSTED_PROXY_COUNT 1 сколько доверенных прокси перед API (см. Rate limit)
RATE_LIMIT_PER_IP ${RATE_LIMIT_PER_IP:-300} лимит запросов в минуту на адрес клиента
SERVICE_GATEWAY_IAM_URL http://iam-service:8010 IAM для обмена identity
SERVICE_GATEWAY_IAM_TENANT_ID ${IAM_TENANT_ID} tenant IAM, в котором зарегистрирован Keycloak
SERVICE_GATEWAY_IDENTITY_PROVIDER keycloak ключ identity provider'а в IAM
SERVICE_GATEWAY_UPSTREAMS JSON (см. ниже) сервисы за шлюзом
S3_ENDPOINT_URL, S3_BUCKET, S3_REGION, S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY MinIO хранилище документов (см. Документы)
EMAIL_TRANSPORT log письма пишутся в журнал, не отправляются
UVICORN_ROOT_PATH /platform префикс пути за прокси (только у platform-api)

Полный список переменных всех сервисов — в справочнике.

Работа за прокси с префиксом пути

Снаружи API доступен под /platform: Caddy срезает префикс (handle_path /platform/*) и передаёт X-Forwarded-Prefix: /platform. Контейнер запускает uvicorn так:

uvicorn platform_api.main:app --host 0.0.0.0 --port 8000 \
  --proxy-headers --forwarded-allow-ips='*' --root-path $UVICORN_ROOT_PATH

Следствия, которые нужно учитывать:

  • Внутри сети путь без префикса. platform-web и сервисы ходят на http://platform-api:8000/api/v1/…, браузер и внешние клиенты — на https://platform.example.com/platform/api/v1/….
  • OpenAPI и ссылки строятся с учётом root_path, поэтому /platform/docs снаружи работает.
  • Rate limit снимает префикс сам. Под --root-path ASGI-путь приходит вместе с префиксом; middleware лимитов вычитает root_path прежде, чем определить группу маршрутов и проверить whitelist (/health, /ready, /version, /metrics). Иначе все запросы попали бы в одну группу operational, а health-пробы — под лимит.
  • Клиенты с базой-префиксом. Если клиент собирает URL как new URL("/api/v1/…", "https://host/platform"), префикс теряется. Путь нужно приклеивать к базе строкой (веб-консоль делает именно так).

Аутентификация запросов

Bearer от Keycloak

Каждый запрос с заголовком Authorization: Bearer <token> проходит проверку в middleware до роутера:

  1. Подпись — по JWKS realm (${KEYCLOAK_URL}/realms/platform/protocol/openid-connect/certs), алгоритмы RS256/RS384/RS512. Ключи кэшируются на час; при незнакомом kid JWKS перечитывается один раз.
  2. iss — ровно ${KEYCLOAK_URL}/realms/platform; aud — содержит platform-api.
  3. Claim tenant_id — обязателен и непуст.
  4. Пользователь с external_id = sub существует в базе, активен; из членств в tenant'е tenant_id строится session context. Роли берутся из realm_access.roles токена.

Tenant запроса всегда берётся из токена: заголовок x-tenant-id при Bearer-аутентификации не используется.

Ответ error_code Причина
401 INVALID_TOKEN подпись, срок, issuer или audience не прошли проверку
401 UNKNOWN_SIGNING_KEY kid не найден даже после перечитывания JWKS
403 MISSING_TENANT_CLAIM в токене нет tenant_id (см. Keycloak → User profile)
401 USER_NOT_FOUND нет пользователя с таким sub в базе панели
401 USER_DEACTIVATED пользователь деактивирован

Ошибки platform-api приходят в конверте платформы:

{
  "error_code": "TENANT_SCOPE_REQUIRED",
  "error_category": "authorization",
  "message": "Tenant scope is required",
  "status_code": 403,
  "request_id": "…",
  "correlation_id": null,
  "details": null
}

Header-identity выключена по умолчанию

Заголовки x-principal-id, x-tenant-id, x-role-codes и x-support-override platform-api принимает, только если установка явно включила TRUST_HEADER_IDENTITY=true — это нужно тестам и внутренним вызовам. По умолчанию (false) запрос без Bearer анонимен: маршруты, требующие аутентификации, отвечают 401. x-support-override при выключенном флаге игнорируется и с валидным Bearer — клиентский заголовок не расширяет права токена.

Второй рубеж — внешний контур: поставляемые Caddyfile (deploy/staging/Caddyfile, deploy/caddy/Caddyfile.local) срезают эти заголовки у всех входящих запросов к /platform/*:

handle_path /platform/* {
    request_header -X-Principal-Id
    request_header -X-Tenant-Id
    request_header -X-Role-Codes
    request_header -X-Support-Override
    reverse_proxy platform-api:8000 {
        header_up X-Forwarded-Prefix /platform
    }
}

Не включайте TRUST_HEADER_IDENTITY на установке, где platform-api доступен снаружи. Порт platform-api на хосте (127.0.0.1:${PLATFORM_API_HOST_PORT}) не публикуйте наружу.

Эндпоинты входа

Метод и путь Что делает
POST /api/v1/auth/login {email, password} → password grant клиентом platform-api; ответ access_token, refresh_token, token_type, expires_in
POST /api/v1/auth/refresh {refresh_token} → новая пара токенов
GET /api/v1/auth/callback?code=&state= обмен authorization code на токены (redirect на …/platform/api/v1/auth/callback)
POST /api/v1/auth/logout logout в Keycloak по сохранённому refresh token и его отзыв
GET /api/v1/auth/me user_id, tenant_id, email, role_bindings, team_scope, resolved_locale, activation_state
curl -s https://platform.example.com/platform/api/v1/auth/login \
  -H 'Content-Type: application/json' \
  -d '{"email":"alice@example.com","password":"<пароль>"}' | jq 'del(.access_token, .refresh_token)'
# {"token_type": "Bearer", "expires_in": 300}

Любая ошибка grant в Keycloak (неверный пароль, «Account is not fully set up», отключённый пользователь в Keycloak) сводится к 401 INVALID_CREDENTIALS; ошибка refresh — к 401 SESSION_REFRESH_FAILED. Истинную причину смотрите прямым запросом к Keycloak (Keycloak → Password grant).

Попытки входа и refresh дополнительно ограничиваются защитой от подбора: ключ входа — пара «адрес клиента + e-mail», ключ refresh — sub из refresh token. Адрес клиента здесь берётся из сетевого соединения (без учёта X-Forwarded-For), поэтому для входов через веб-консоль счётчик фактически считается по e-mail.

Шлюз к сервисам

Маршрут

{GET|POST|PUT|PATCH|DELETE} /api/v1/services/{service}/{path}

Шлюз принимает только Bearer Keycloak с полноценным session context. Для каждого запроса:

sequenceDiagram
    participant C as Клиент (platform-web)
    participant G as platform-api (шлюз)
    participant I as iam-service
    participant U as upstream (например control-plane-api)
    C->>G: GET /api/v1/services/control-plane/api/v1/tasks (Bearer Keycloak)
    G->>G: проверка токена, session context
    alt токен upstream в кэше
        G->>G: взять из кэша (principal, service)
    else
        G->>I: POST /api/v1/tenants/{tenant}/federation:exchange
        I-->>G: accessToken, expiresIn
    end
    G->>U: GET /api/v1/tasks (Bearer IAM, audience upstream)
    U-->>G: ответ
    G-->>C: статус и тело как есть
  1. service ищется в SERVICE_GATEWAY_UPSTREAMS; нет такого — 404 gateway.unknown_service.
  2. Токен upstream берётся из кэша процесса по ключу (user_id, service) или обменивается в IAM:

    POST {SERVICE_GATEWAY_IAM_URL}/api/v1/tenants/{SERVICE_GATEWAY_IAM_TENANT_ID}/federation:exchange
    {
      "identityProvider": "keycloak",
      "token": "<Bearer Keycloak вызывающего>",
      "audience": "control-plane",
      "scopes": ["control-plane:read", "control-plane:write", "control-plane:admin"]
    }
    

    Кэш считает токен протухшим на 30 с раньше expiresIn; параллельные запросы одного человека к одному сервису делают один обмен (блокировка по ключу). Кэш живёт в памяти процесса: несколько реплик platform-api обменивают токены независимо. 3. Запрос проксируется на {base_url}/{path}?{query} с Authorization: Bearer <токен IAM>.

Что проходит через шлюз

Направление Заголовки
В upstream content-type, accept, if-match, idempotency-key, плюс x-request-id платформы и x-correlation-id (если есть)
Обратно клиенту content-type, etag, x-request-id upstream'а (если отличается от платформенного), idempotency-replayed

Всё остальное (cookie, служебные x-*, hop-by-hop) остаётся на границе. Статус и тело ответа upstream возвращаются без изменений, включая его 4xx/5xx в собственном конверте upstream'а.

Ограничения шлюза

  • Ответ upstream буферизуется целиком — потоковые ответы (SSE, большие файлы потоком) через шлюз не работают.
  • Allow-list путей у шлюза намеренно нет: точкой принятия решения остаётся upstream (Policy Enforcement Point). Отклоняются только пути с сегментами . и .. — 422 gateway.invalid_path.
  • Таймаут запроса к IAM и upstream — SERVICE_GATEWAY_TIMEOUT_SECONDS (по умолчанию 10 с).

Ошибки шлюза

Собственные ошибки шлюза — в конверте платформы с кодами gateway.*:

Статус error_code Когда
404 gateway.unknown_service сервис не настроен в SERVICE_GATEWAY_UPSTREAMS
422 gateway.invalid_path в пути есть . или ..
401 gateway.identity_rejected IAM не подтвердил токен Keycloak (401 на federation:exchange); код IAM — в details.identity_error
403 gateway.identity_forbidden IAM подтвердил identity, но отказал в audience/scopes (audience_not_allowed, scope_not_allowed, human_principal_required)
503 gateway.identity_unavailable IAM недоступен, ответил 5xx/иным кодом или прислал некорректный ответ; причина — в details.reason
503 gateway.upstream_unreachable upstream не ответил (сеть, таймаут)
503 gateway.not_initialized шлюз не инициализирован

Ошибки самого upstream приходят в его конверте ({"error": {"code", "message", "details", "requestId"}}). Например, если у IAM-principal'а человека нет binding'а в Control Plane, Control Plane отвечает 401 invalid_credentials (точная причина binding_not_found — только в его журнале), а при нехватке прав — 403 permission_denied.

Настройка upstream'ов

SERVICE_GATEWAY_UPSTREAMS — JSON-объект «ключ сервиса → описание»:

{
  "control-plane": {
    "base_url": "http://control-plane-api:8000",
    "audience": "control-plane",
    "scopes": ["control-plane:read", "control-plane:write", "control-plane:admin"]
  },
  "acme-pack": {
    "base_url": "http://acme-pack-api:8000",
    "audience": "acme-pack",
    "scopes": ["acme-pack:read", "acme-pack:write"]
  }
}
Поле Обязательно По умолчанию Правила
ключ да — непустая строка без /; это {service} в пути шлюза
base_url да — база upstream'а внутри сети; завершающий / отбрасывается
audience нет ключ сервиса audience IAM, на который меняется токен
scopes нет [] запрашиваемые scopes; пустой список означает «всё, что разрешено audience»

Проверки при старте: некорректный JSON или поле — ошибка конфигурации, процесс не стартует. Если задан хотя бы один upstream, обязательны SERVICE_GATEWAY_IAM_URL и SERVICE_GATEWAY_IAM_TENANT_ID — лучше упасть на старте, чем отдавать 503 на каждый запрос. Пустой SERVICE_GATEWAY_UPSTREAMS допустим: шлюз смонтирован, любой запрос получает 404 gateway.unknown_service.

Чтобы подключить новый сервис за шлюзом:

  1. Завести в IAM audience сервиса со списком allowedScopes (для платформенных audience это делает make bootstrap).
  2. Сервис должен проверять токены IAM своего audience через platform-auth-sdk.
  3. Добавить запись в SERVICE_GATEWAY_UPSTREAMS (в .env, переменная целиком переопределяет значение по умолчанию из compose.yml) и пересоздать platform-api.

Подключение человека к сервисам

Чтобы человек из консоли работал с Control Plane, нужны три записи:

Где Что Зачем
IAM human principal в tenant'е и внешняя identity (issuer realm, sub) federation:exchange находит principal'а по issuer + sub
Control Plane локальный principal и IAM-binding (issuer IAM, iam_principal_id) с правами Control Plane переводит токен IAM в свои permissions
platform-api / Keycloak пользователь панели (см. Keycloak → Пользователи) вход и session context

Если внешняя identity ещё не привязана, первый обмен создаёт для человека новый human principal в IAM автоматически. Но binding в Control Plane для него не появится сам — Control Plane ответит 401 invalid_credentials. Поэтому надёжнее завести всё заранее:

IAM=https://platform.example.com/iam
H="X-IAM-Bootstrap-Token: $IAM_BOOTSTRAP_TOKEN"

# 1. human principal в IAM
PRINCIPAL=$(curl -s -X POST "$IAM/api/v1/tenants/$IAM_TENANT_ID/principals" -H "$H" \
  -H 'Content-Type: application/json' -d '{"kind":"human","displayName":"Alice Example"}' | jq -r .id)

# 2. привязать пользователя Keycloak (sub) к principal
curl -s -X POST "$IAM/api/v1/tenants/$IAM_TENANT_ID/principals/$PRINCIPAL/external-identities" \
  -H "$H" -H 'Content-Type: application/json' \
  -d '{"issuer":"https://platform.example.com/auth/realms/platform","subject":"<keycloak-sub>"}'

Затем в Control Plane — локальный principal и binding с правами (нужен PAT администратора Control Plane, scope control-plane:admin):

CP=https://platform.example.com
CP_PRINCIPAL=$(curl -s -X POST "$CP/api/v1/principals" -H "Authorization: Bearer $CP_ADMIN_TOKEN" \
  -H 'Content-Type: application/json' -d '{"kind":"human","displayName":"Alice Example"}' | jq -r .id)

curl -s -X POST "$CP/api/v1/principals/$CP_PRINCIPAL/iam-bindings" \
  -H "Authorization: Bearer $CP_ADMIN_TOKEN" -H "Idempotency-Key: $(uuidgen)" \
  -H 'Content-Type: application/json' \
  -d '{
    "issuer": "https://platform.example.com/iam",
    "iamTenantId": "'"$IAM_TENANT_ID"'",
    "iamPrincipalId": "'"$PRINCIPAL"'",
    "permissions": ["sessions.open", "tasks.read", "tasks.write", "tasks.claim",
                    "events.read", "artifacts.read", "artifacts.write", "projects.read"]
  }'

Состав прав и их смысл — в Авторизация и права и справочнике прав.

Сначала binding, потом первый запрос

Control Plane кэширует и отрицательный ответ проверки binding'а. Если человек успел обратиться к консоли до создания binding'а, отказ держится до истечения окна устаревания кэша (CP_IAM_BINDING_STALE_AFTER_SECONDS, по умолчанию 120 с). Заводите binding до того, как пользователь войдёт в консоль.

Проверка цепочки без браузера (password grant допустим только для диагностики):

KC_TOKEN=$(curl -s "$CP/auth/realms/platform/protocol/openid-connect/token" \
  -d grant_type=password -d client_id=platform-api \
  --data-urlencode "client_secret=$KEYCLOAK_CLIENT_SECRET" \
  -d username=alice@example.com --data-urlencode "password=<пароль>" | jq -r .access_token)

curl -s "$CP/platform/api/v1/auth/me" -H "Authorization: Bearer $KC_TOKEN" | jq .
curl -s "$CP/platform/api/v1/services/control-plane/api/v1/harness/context" \
  -H "Authorization: Bearer $KC_TOKEN" | jq '.principal, .permissions'

Rate limit

Как считается

Лимит — скользящее окно 60 с в Redis, ключ rl:ip:{группа}:{адрес клиента}. Группа маршрута — первый сегмент пути после /api/v1 (auth, documents, services, users…); пути вне /api/v1 — группа operational.

Переменная По умолчанию Действие
RATE_LIMIT_PER_IP 100 (в compose — 300) запросов в минуту на адрес для всех групп; 0 выключает лимит
RATE_LIMIT_AUTH_PER_IP 20 отдельный лимит группы auth (/api/v1/auth/*)
RATE_LIMIT_PER_TENANT 1000 лимит на tenant для маршрутов, подключивших tenant-проверку
TRUSTED_PROXY_COUNT 0 (в compose — 1) сколько доверенных прокси стоит перед API

Не лимитируются /health, /ready, /version, /metrics.

Каждый ответ несёт x-ratelimit-limit, x-ratelimit-remaining, x-ratelimit-reset; превышение — 429 с retry-after и телом:

{"error_code": "RATE_LIMIT_EXCEEDED", "error_category": "rate_limited",
 "message": "Too many requests. Please retry after 12 seconds."}

Если Redis недоступен, запрос пропускается без лимита (и растёт метрика ошибок Redis) — доступность панели важнее лимита.

Адрес клиента и доверенные прокси

При TRUSTED_PROXY_COUNT = N > 0 адрес клиента берётся из X-Forwarded-For: элемент с позиции len - N - 1; если такой позиции нет — первый элемент списка. При N = 0 — адрес TCP-соединения.

Типовая раскладка — одна цепочка через Caddy:

Путь запроса X-Forwarded-For у platform-api Адрес для лимита
браузер → Caddy → platform-api <браузер> (Caddy подставляет адрес клиента) <браузер>
браузер → Caddy → platform-web → platform-api <браузер> (веб пробрасывает заголовок) <браузер>

Серверные вызовы веб-консоли

Серверные компоненты и route handlers веб-консоли ходят в platform-api с одного адреса — контейнера platform-web. Если бы они не пробрасывали X-Forwarded-For браузера, все пользователи делили бы один лимит, и префетч нескольких экранов исчерпывал бы его. Веб-консоль пробрасывает заголовок во всех серверных вызовах (clientForwardHeaders). Если вы пишете свой серверный клиент к platform-api, делайте так же.

Если перед Caddy стоит ещё один балансировщик, добавляющий себя в X-Forwarded-For, увеличьте TRUSTED_PROXY_COUNT и настройте доверенные прокси в Caddy — иначе клиент сможет подделать свой адрес.

Прочие группы API

Помимо шлюза и входа platform-api обслуживает (все под /api/v1):

Группа Назначение
users профиль, me/feature-flags, аватар, смена роли, деактивация
invites приглашения пользователей
tenants, admin-config настройки tenant'а, feature flags, политики локалей, журнал аудита
documents, files документы и файлы (см. Документы)
notifications (в users/me/…) уведомления, в том числе SSE-поток
billing, admin/billing, integrations, webhooks, pd биллинг, интеграции, персональные данные

Спецификация — https://platform.example.com/platform/docs.

Здоровье и наблюдаемость

Эндпоинт Назначение
GET /health liveness (используется healthcheck контейнера)
GET /ready readiness зависимостей
GET /version версия сборки
GET /metrics метрики Prometheus

Журнал — structlog (LOG_RENDERER=json в compose). Полезные события: service_gateway_identity_unavailable, service_gateway_upstream_unreachable, rate_limit_exceeded, auth_failure.

Типичные проблемы

Симптом Причина Что делать
404 gateway.unknown_service ключ сервиса не совпадает с SERVICE_GATEWAY_UPSTREAMS или переменная не передана проверить .env и docker compose config
401 gateway.identity_rejected с identity_error=identity_provider_not_found Keycloak не зарегистрирован в IAM tenant'е SERVICE_GATEWAY_IAM_TENANT_ID Keycloak → Связь с IAM
403 gateway.identity_forbidden audience не заведён в IAM или запрошены scopes вне allowedScopes сверить audience и scopes
401 invalid_credentials от Control Plane через шлюз нет binding'а у IAM-principal'а человека Подключение человека
403 permission_denied от Control Plane в binding'е нет нужного права дополнить permissions binding'а
503 gateway.identity_unavailable сразу после старта пустой IAM_TENANT_ID make bootstrap, вписать tenant, пересоздать platform-api
Частые 429 у всех пользователей сразу лимит считается по адресу прокси TRUSTED_PROXY_COUNT, проброс X-Forwarded-For

См. также