ADR-0051: Public API auth and API-key RBAC
Status: accepted Date: 2026-05-05 Deciders: Максим Белканов, Tracium engineering
Контекст
Tracium продаёт программный доступ к API: поиск товаров, остатков, аналогов,
proposal pipeline и будущие заказы. Это системное взаимодействие customer
backend → Tracium, а не пользовательская web-сессия. До этого момента часть
customer-callable endpoints была открыта без auth, часть работала только через
JWT-cookie кабинета, а API-key валидация делалась in-process через
BearerMiddleware с PG lookup на каждый запрос.
Нужно закрыть утечку данных, не смешать пользовательский JWT с API-токенами,
дать customer’ам управляемые scope’ы в кабинете и сохранить быстрый request
path, сравнимый с JWT auth_request.
Решение
Разделяем два типа identity
- JWT-cookie остаётся механизмом пользовательской сессии для cabinet/admin UI.
JWT валидируется через nginx
auth_requestк auth-service; backend получает только trusted user/customer context. - API token (
Authorization: Bearer trk_*) становится механизмом system-to-system public API. Токен opaque для клиента, хранится только как HMAC-SHA-256 hash с pepper, валидируется по PG в одном internal verify endpoint.
JWT и API tokens не являются взаимозаменяемыми. /api/cabinet/* остаётся
JWT-only. /v1/* public data API по умолчанию API-token-only, кроме явно
описанного dual-auth переходного endpoint’а search proposals.
Fast path через nginx auth_request
Public API token validation переносится на nginx:
- Customer присылает
Authorization: Bearer trk_*. - nginx делает subrequest
/_internal/auth/api-key/verify. - api-server verify handler проверяет hash, key status, expiry и customer status.
- nginx кэширует успешный/неуспешный subrequest на 30 секунд по Authorization.
- Основной request получает trusted headers:
X-Tr-Customer-Id,X-Tr-Key-Id,X-Tr-Key-Env,X-Tr-Key-Name,X-Tr-Key-Role,X-Tr-Key-Scopes. - Handlers читают
internal/platform/apikeyauth.Subjectи не ходят в БД.
Зомби-окно revoke до 30 секунд принимается как trade-off, аналогичный короткому JWT/cache TTL. Future invalidation через purge/outbox не входит в текущий этап.
Trust boundary обязателен в nginx
api-server доверяет X-Tr-* только если они пришли через nginx. Поэтому каждый
nginx location с proxy_pass http://api_service обязан include’ить один общий
snippet api-trusted-headers.conf, который:
- задаёт обычные proxy headers;
- перезаписывает все
X-Tr-*из server variables; - не пропускает incoming customer-supplied
X-Tr-*.
Нельзя полагаться на proxy_set_header на server{} уровне: nginx не наследует
эти директивы в location, если location задал хотя бы один свой proxy_set_header.
Все server-блоки, проксирующие в api-service, также include’ят
api-trust-defaults.conf с пустыми defaults и api-internal-block.conf с
location /_internal/ { internal; }.
Scope model
Scopes are source of truth. Role is only a preset expanded into scopes at create time and saved for display/recreate semantics.
Launch active scopes:
whoami— sentinel, always granted for any valid API token.products:read—GET /v1/products,GET /v1/products/{id_or_viewable}.search:read—POST /v1/search/proposals.
Planned scopes are documented but not grantable until their endpoints ship:
credentials:read, credentials:write, orders:write. CreateKey rejects
planned scopes with apikey_scope_not_active. No backfill grants planned scopes
to existing keys; customers intentionally reissue keys after new scopes are
announced.
Launch active role: viewer only. editor and admin are reserved/dormant
until write endpoints and their customer consent UX exist.
Rollout
Phase 1 closes the public leak and moves auth validation to nginx. Existing I4a
keys receive viewer scopes from verify-handler fallback:
whoami,products:read,search:read. Scope checks are active from day one.
Phase 2 adds DB columns api_keys.scopes and api_keys.role, backfills existing
keys with the same viewer scopes, exposes role/scopes in cabinet UI/API, and
adds admin-side key creation under the same active/planned rules.
Route and OpenAPI convention
External canonical public URLs are /v1/*.
public-api.openapi.yaml uses servers with /v1 base path, so OpenAPI path keys
omit /v1: /products, /products/{id}, /search/proposals, /whoami.
Do not add /v1/... path keys to that file.
Legacy aliases /api/v1/products* and /api/v1/search/proposals may remain in
nginx/backend for compatibility, but they are not the canonical SDK contract.
cabinet-api.openapi.yaml describes only /api/cabinet/*; it must not absorb
/api/v1/search/proposals.
Последствия
Плюсы
- Public API получает отдельный machine-token auth, не конфликтующий с JWT users.
- Cache hit path не делает PG lookup на каждый request.
- Scope checks становятся явными в handlers через
RequireScope. - Planned destructive scopes не превращаются в silent privilege escalation.
- OpenAPI base-path convention становится однозначным.
Минусы
- nginx config становится security-critical and must be linted.
- Revoke takes effect after nginx cache TTL, up to 30 seconds.
- Dual-auth search proposals requires nginx dispatcher complexity during transition.
Нейтральные последствия
BearerMiddlewarecan remain temporarily for legacy/v1/whoami, but new public handlers must use nginx trusted headers.- Future OAuth2 client credentials token exchange is still possible, but current launch remains single-step opaque Bearer token.
Рассмотренные альтернативы
Self-contained JWT-like API tokens
Подписывать API tokens как JWT/PASETO и проверять без БД. Не выбрано сейчас: нужны immediate revoke, last_used tracking, customer status checks and future IP/tier constraints. Opaque token + cached introspection gives enough speed with better operational control.
In-process BearerMiddleware everywhere
Простее в Go, но добавляет PG lookup на каждый public request and spreads auth logic across handlers. Не подходит для API как продаваемого high-throughput surface.
Full OAuth2 client credentials now
Стандартизировано, но добавляет token exchange, expiry/refresh semantics and client secret lifecycle. Это отдельная итерация после usage tracking/IP allowlist; текущий customer need закрывается single-step Bearer token.