Шлюз 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-pathASGI-путь приходит вместе с префиксом; middleware лимитов вычитаетroot_pathпрежде, чем определить группу маршрутов и проверить whitelist (/health,/ready,/version,/metrics). Иначе все запросы попали бы в одну группуoperational, а health-пробы — под лимит. - Клиенты с базой-префиксом. Если клиент собирает URL как
new URL("/api/v1/…", "https://host/platform"), префикс теряется. Путь нужно приклеивать к базе строкой (веб-консоль делает именно так).
Аутентификация запросов¶
Bearer от Keycloak¶
Каждый запрос с заголовком Authorization: Bearer <token> проходит проверку
в middleware до роутера:
- Подпись — по JWKS realm (
${KEYCLOAK_URL}/realms/platform/protocol/openid-connect/certs), алгоритмы RS256/RS384/RS512. Ключи кэшируются на час; при незнакомомkidJWKS перечитывается один раз. iss— ровно${KEYCLOAK_URL}/realms/platform;aud— содержитplatform-api.- Claim
tenant_id— обязателен и непуст. - Пользователь с
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.
Шлюз к сервисам¶
Маршрут¶
Шлюз принимает только 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: статус и тело как есть
serviceищется вSERVICE_GATEWAY_UPSTREAMS; нет такого —404 gateway.unknown_service.-
Токен 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.
Чтобы подключить новый сервис за шлюзом:
- Завести в IAM audience сервиса со списком
allowedScopes(для платформенных audience это делаетmake bootstrap). - Сервис должен проверять токены IAM своего audience через platform-auth-sdk.
- Добавить запись в
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 |