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:

  1. Customer присылает Authorization: Bearer trk_*.
  2. nginx делает subrequest /_internal/auth/api-key/verify.
  3. api-server verify handler проверяет hash, key status, expiry и customer status.
  4. nginx кэширует успешный/неуспешный subrequest на 30 секунд по Authorization.
  5. Основной 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.
  6. 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:readGET /v1/products, GET /v1/products/{id_or_viewable}.
  • search:readPOST /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.

Нейтральные последствия

  • BearerMiddleware can 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.

Ссылки