ADR-0041: Credentials Pool and Secrets Isolation

Status: accepted Date: 2026-04-27 Deciders: Maxim Belkanov (architect), agent-claude (implementer)

Контекст

До P4c-2 credentials в Tracium жили в StubRouter — in-memory структуре, засеянной из переменных окружения (INGESTION_EXTRA_CREDENTIALS, плюс три hardcoded system-credential’а etm/iek/systeme). Эта форма имела ряд критических ограничений, которые стали заметны по мере роста числа поставщиков и нагрузки:

Проблемы StubRouter

  1. Нет rotation без перезапуска. Секрет живёт в env-var, смена ключа поставщика = change env + restart. Downtime даже при rolling deploy.

  2. Нет rate budget. StubRouter выдавал любую credential без учёта текущей нагрузки на неё. Supplier 429 обнаруживался только реактивно (connector получал 429, логировал, следующий запрос шёл на ту же перегруженную credential).

  3. Нет health-aware fail-over. При 401/403 (credential отозван) или серии 5xx (supplier временно нестабилен) StubRouter продолжал отдавать ту же credential, раз за разом получая ошибку.

  4. Секреты в env-переменных. Env-vars видны во всех инструментах ops: docker inspect, /proc/<pid>/environ, лог-агрегатор при старте, CI/CD pipeline secrets dump. Не удовлетворяет базовой операционной гигиене.

  5. Нет операторского CRUD. Добавить credential = изменить deployment манифест, сделать PR, дождаться merge + deploy. Оперативное реагирование (supplier предоставил новый API key) — невозможно.

  6. Нет audit trail. Кто изменил, когда, зачем — нет. Outbox-events не эмитятся при изменении env-vars.

  7. Один credential на supplier. StubRouter не поддерживал пул. Load distribution между несколькими API-ключами поставщика — недостижима.

Spec зафиксирован в docs/superpowers/specs/2026-04-26-p4c-2-credentials-pg-migration-design.md. P4c-2 является частью roadmap-фазы P4 (Discovery polish), которая закрывает credential-routing как production-grade подсистему.

Решение

P4c-2 вводит полный credential-management slice: домен + инфраструктура + application-layer + HTTP admin API + CLI migration tool. Ключевые архитектурные выборы — ниже.


1. Два класса credentials: system и customer

Каждая credential имеет поле scope:

  • system — принадлежит платформе, используется для всех system-операций (CatalogSync, PriceRefresh, StockRefresh, DiagnosticProbe). Поставщик выдаёт API-ключ для Tracium как платформы.
  • customer — принадлежит конкретному customer’у (owner_ref = customer UUID). Используется для CustomerProposal (получение цен для клиента через его договорной ключ с поставщиком). Может использоваться для system-операций (cross-scope reuse) если клиент дал согласие.

Разделение позволяет точно управлять тем, какой ключ использовать для каждой операции — без риска перепутать contract-pricing customer key и system key.

Cross-scope reuse: Pool.Pick с intent=CatalogSync и customerID=<id> сначала ищет system credentials для данного supplier’а, затем (если не нашёл) — customer credentials того же customer’а. Это позволяет использовать customer-owned ключ для синхронизации каталога по договорным ценам клиента без создания дубликата system credential.


2. CredentialUsage enum

Каждая credential объявляет allowed_usages []CredentialUsage. Caller передаёт intent в PickRequest. Pool отфильтровывает credentials, в allowed_usages которых intent отсутствует.

Стартовый набор enum-значений:

ЗначениеОписание
CustomerProposalProposal-запрос от имени customer’а
CatalogSyncФоновая синхронизация каталога
PriceRefreshФоновое обновление цен
StockRefreshФоновое обновление остатков
DiagnosticProbeHealth/connectivity probe
OrderPlacementРазмещение заказа (зарезервировано, API поставщиков не поддерживают)

CredentialUsage — typed string (не iota). Хранится в PG как TEXT[].


3. PoolSelector strategy interface

PoolSelector — интерфейс с методом Pick(candidates []*CredentialRecord) (*CredentialRecord, error). Реализации заменяемы без изменения CredentialPoolService.

Shipped реализация: Round-Robin — atomic counter % len(candidates). Детерминирован, O(1), не требует внешнего состояния.

Отложенные стратегии (не вошли в P4c-2, см. §Deferred items):

  • Weighted (credential имеет weight int) — для разных rate-limit профилей на одном supplier’е.
  • Least-loaded (реально consumed calls in last window) — адаптивный.

Разделение PoolSelector / PoolService позволяет тестировать стратегию изолированно и подменять через fx на prod.


4. Health tracking через Redis

HealthTracker — интерфейс с методами BlockedUntil, Block, Reset. Реализация хранит ключ credentials:health:<credential_id> в Redis с TTL = blocked_until - now.

Почему Redis, а не PG: Health state — hot-path ephemeral данные. Читается при каждом Pool.Pick вызове (несколько раз в секунду). PG round-trip на каждый Pick — неприемлемо. Redis TTL-expiry нативно выражает cooldown.

Multi-replica consistency: Все реплики api-server используют один Redis кластер. Если replica A заблокировала credential (получила 429), replica B немедленно видит cooldown и не делает дополнительный запрос к supplier’у. Без Redis каждая replica имела бы независимый in-memory state.


5. Proactive rate budgeting через platform/ratebuckets

RateBudget — интерфейс TryAcquire / Available / Reset. Реализация оборачивает существующий internal/platform/ratebuckets (token bucket per key в Redis).

Bucket key: credentials:bucket:<credential_id>:rpm. TTL = 60s (rolling window).

Проактивность: не ждём 429 от supplier’а. Если credential имеет RateLimit.RequestsPerMinute = N, мы сами не отдаём её из пула если bucket исчерпан. Supplier получает ровно N RPM независимо от нагрузки на platfrom.


6. Cooldown policy (hardcoded defaults)

При Pool.ReportFailure(credentialID, statusCode, retryAfterHeader):

СитуацияCooldown
429 + Retry-After headerClamp(retryAfterValue, 10s, 1h)
429 без Retry-After60s
401 / 40324h (effectively manual unlock)
5xx / timeout30s
400 / 404 / другие 4xxНе блокируем (caller bug, не credential)

Cooldown хранится в Redis через HealthTracker.Block. Ручная разблокировка через admin endpoint POST /api/v1/admin/credentials/{id}/reset-health.

Политика hardcoded — не per-supplier конфигурируема. Для 3 текущих поставщиков достаточно. Расширение — см. §Deferred items.


7. Секретное хранилище: dedicated PG table + 3 PG roles

Схема хранения секретов:

supplier_credentials           (metadata only; secret NOT present)
supplier_credential_secrets    (encrypted_value BYTEA; encrypted_with TEXT)

Разделение на две таблицы позволяет применить PG role-based isolation:

PG рольПрава на credentialsПрава на secrets
credentials_adminSELECT, INSERT, UPDATE, DELETEНЕТ
credentials_secrets_readerSELECT (id only)SELECT
credentials_secrets_writerНЕТ (через credentials_admin connection)INSERT, UPDATE, DELETE

Правила применяются через 3 независимых PG connection pool’а в fx wiring:

  • AdminPoolcredentials_admin user (metadata CRUD + admin HTTP).
  • SecretsReaderPoolcredentials_secrets_reader user (hot-path Pick).
  • SecretsWriterPoolcredentials_secrets_writer user (Create/RotateSecret).

Защита от ошибки в коде: если в admin HTTP handler случайно появится код SELECT secret_value FROM supplier_credential_secrets, он получит PG permission denied — не SQL error в логе, а реальный access control.


8. Application-level AES-256-GCM шифрование, versioned master-key

Несмотря на PG role isolation, предположим worst-case: DBA получил root доступ к PG. Без app-level шифрования секрет читается plaintext.

Решение: AESEncrypter (stdlib crypto/aes + crypto/cipher) шифрует каждый секрет перед записью в supplier_credential_secrets.encrypted_value.

Формат ciphertext: v<version>:<base64(nonce+ciphertext+tag)>. Колонка encrypted_with хранит версию ключа (e.g. "v1").

Master-key поступает из env CREDENTIALS_MASTER_KEY. При необходимости ротации вводится CREDENTIALS_MASTER_KEY_V2 рядом с V1 (детали — в runbook docs/operations/credentials.md).


9. Outbox events (8 топиков)

Все state-changing операции эмитят outbox-события через существующий platform/outbox.Publisher (топики в supplier-network домене):

ТопикКогда
credential.created.v1Создание credential
credential.metadata_updated.v1Обновление метаданных
credential.secret_rotated.v1Ротация секрета (без значения)
credential.status_changed.v1Переход статуса
credential.deleted.v1Удаление
credential.secret_accessed.v1Каждый успешный Pick
credential.health_changed.v1Блокировка через ReportFailure
credential.health_reset.v1Ручная разблокировка

credential.secret_rotated.v1 и credential.secret_accessed.v1 — без значения секрета в payload. Полный audit trail без риска утечки.


10. Admin HTTP CRUD с обязательным reason

8 endpoints под /api/v1/admin/credentials/:

Метод + путьОперация
GET /Список (filters: supplier_ref, scope, status, owner_ref)
GET /{id}Одна credential (secret_value отсутствует/redacted)
POST /Создать (тело содержит secret_value)
PUT /{id}Обновить метаданные (без секрета)
PATCH /{id}/secretРотировать секрет
PATCH /{id}/statusИзменить статус
POST /{id}/reset-healthСбросить cooldown
DELETE /{id}Удалить (только если status=revoked, иначе 409)

Все state-changing endpoints (POST create, PUT, PATCH, DELETE) требуют поле reason в теле запроса. reason пишется в outbox event. Без reason → 400.


11. LegacyCredentialRouter adapter

Существующий интерфейс CredentialRouter.Select(briefing) []CredentialRoutingResult используется discovery-pipeline’ом. Переписывать всё pipeline вместе с P4c-2 — излишне.

Adapter LegacyCredentialRouter реализует старый интерфейс через новый CredentialPool.Pick(intent=CustomerProposal, ...). Discovery-pipeline продолжает работать без изменений. Adapter убирается в следующей pipeline рефакторинг-итерации (нет жёсткого срока).


Последствия

Плюсы

  • Load distribution. Несколько API-ключей одного supplier’а образуют пул. Нагрузка распределяется round-robin. Rate limit каждого ключа используется эффективно.

  • Операторский контроль. Добавить, обновить, отозвать credential через admin API без deploy. Аудит через outbox events. Ротация секрета без downtime.

  • Secret hygiene. Секреты выходят из env-переменных в зашифрованное PG-хранилище с role isolation. Env-var INGESTION_EXTRA_CREDENTIALS удалена из production-пути.

  • Audit trail. 8 outbox топиков покрывают весь lifecycle credential’а. Каждый Pick логируется без раскрытия значения секрета.

  • Health-aware routing. Заблокированные/rate-exhausted credentials автоматически пропускаются. Multi-replica consistent через Redis.

  • Proactive rate control. Token bucket прекращает выдавать credential до 429, не после.

Минусы

  • Operational complexity: master-key rotation. Rotirovat’ master-key — нетривиальная операция (см. runbook). Нет автоматического re-encrypt loop. Пока ручная процедура.

  • 4 PG connections вместо 1. Три role-segregated pools + основной app pool. На малых deployment’ах (Docker Compose dev) — накладные расходы. На prod (PgBouncer) — пулируется прозрачно.

  • Redis dependency в hot path. Pool.Pick делает Redis GET (BlockedUntil)

    • Redis INCRBY (RateBudget.TryAcquire). Если Redis недоступен — Pick возвращает ошибку. Acceptance: Redis уже используется в platform/ratebuckets и cache layers; это не новая зависимость.
  • Сложность процедуры seed-credentials. One-time migration env→DB требует ручного ввода секретов (или подготовки secrets-file). Нет автоматического импорта из env при старте.

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

  • LegacyCredentialRouter adapter вводит временный слой абстракции. Не критично — adapter тонкий, unit-tested.

  • Env var INGESTION_EXTRA_CREDENTIALS полностью удалена из production кода. Seed-credentials CLI использует её как optional input для migration.

  • OrderPlacement usage зарезервирован в enum — не реализован ни одним поставщиком. Forward-compatibility.

Митигации

  • Master-key rotation: runbook описывает процедуру; автоматический CLI инструмент для re-encrypt — deferred, но процедура документирована.

  • Redis dependency: HealthTracker и RateBudget реализованы как интерфейсы. В экстремальном сценарии (Redis outage) можно подключить no-op / in-memory реализацию для деградации (не реализовано в P4c-2, но архитектурно возможно).

  • 4 PG connections: PgBouncer в transaction mode pool’ирует соединения. Три дополнительных logical user’а = три отдельных connection pool’а в PgBouncer, физически 3-5 соединений при idle.


Альтернативы, отклонённые

A. Vault / external secrets manager

HashiCorp Vault или аналог для хранения API-ключей. Предоставляет dynamic secrets, lease rotation, fine-grained audit.

Отклонено: Слишком много новой инфраструктуры для 3 поставщиков. Vault cluster + агент + renewal cycle + TLS между Tracium и Vault — новая точка отказа. Deferred до Phase 7 «security» когда либо масштаб потребует, либо compliance требования вынудят. AES-256-GCM в PG покрывает threat model для текущего масштаба.

B. Только PG, без app-level шифрования

Хранить секреты в supplier_credential_secrets.plaintext TEXT — без шифрования, только PG role isolation.

Отклонено: Defence-in-depth требует: даже при компрометации PG бэкапа или DBA-доступа секреты не читаемы plaintext. AES-256-GCM stdlib implementation — минимальная стоимость, значительный security benefit.

C. Единственная PG роль для всего credentials BC

Один DB user tracium_app с правами SELECT/INSERT/UPDATE/DELETE на обе таблицы (metadata + secrets).

Отклонено: Нарушает defence-in-depth. Ошибка в admin HTTP handler (случайный JOIN с secrets) — утечка секретов. Три роли = три уровня изоляции; cost — 2 дополнительных PG user’а (тривиально).

D. In-memory snapshot + Redis pub/sub для invalidation

Загружать credentials в память при старте, обновлять через Redis pub/sub при admin write.

Отклонено: Чрезмерная сложность. Credentials — не high-frequency read объект (не тысячи в секунду). Прямой PG read на Pick — тоже вариант, но Redis для health/rate уже присутствует; hot-path latency без кэша приемлема при наличии connection pool. Snapshot deferred (не нужен в P4c-2 scope).


Реализация

Затронутые файлы и компоненты

Миграции (Goose, backend/migrations/):

  • 0031_create_supplier_credentials.sql — основная таблица + CHECK constraints + индексы.
  • 0032_create_supplier_credential_secrets.sql — таблица секретов (BYTEA ciphertext + encrypted_with TEXT).
  • 0033_create_credentials_admin_role.sql — PG роль credentials_admin.
  • 0034_create_credentials_secrets_roles.sql — PG роли credentials_secrets_reader + credentials_secrets_writer.

Domain (backend/internal/core/credentials/domain/):

  • usage.goCredentialUsage enum (6 значений + IsValid).
  • rate_limit.goRateLimit { RequestsPerMinute int } VO.
  • descriptor.goCredentialDescriptor (output Pool.Pick с plaintext secret).
  • pool.goCredentialPool interface + PickRequest + domain errors.
  • repository.go — 6 collaborator interfaces: Repository, SecretsRepository, HealthTracker, RateBudget, PoolSelector, Encrypter.
  • scope_resolver.goresolveScope(intent, customerID) CredentialScope pure function.

Infrastructure (backend/internal/core/credentials/infra/):

  • postgres/credential_repository.go — PG-backed Repository.
  • postgres/secrets_repository.go — PG-backed SecretsRepository.
  • crypto/aes_encrypter.go — AES-256-GCM versioned master-key.
  • redis/health_tracker.gocredentials:health:<id> key + TTL.
  • ratebudget/rate_budget.go — обёртка над platform/ratebuckets.
  • selector/round_robin.go — atomic counter round-robin.

Application (backend/internal/core/credentials/app/):

  • pool_service.goCredentialPoolService (read path).
  • credential_service.go — write path (Create/Update/Rotate/Transition/Reset/Delete).
  • legacy_router.goLegacyCredentialRouter adapter.
  • dto.go — application-level DTOs.

HTTP layer (backend/internal/core/credentials/api/http/):

  • handler.go — 8 admin endpoints.
  • dto.go — HTTP DTOs (no secret_value в output).
  • router.go — route registrations.

CLI (backend/cmd/seed-credentials/main.go) — one-time migration tool.

fx wiring (backend/internal/core/credentials/fx.go) — 4 PG pools + providers для всех новых компонентов; удалён provideStubRouter.

Номера commit’ов

P4c-2 реализован в серии коммитов начиная с 408faf8 (pre-P4c-2 HEAD). Общее количество коммитов: ~35-50 в цикле P4c-2 (Phases A-M). HEAD после полного цикла: см. git log --oneline 408faf8..HEAD.


Deferred items

  1. Vault migration. Когда compliance или масштаб потребуют, заменить AESEncrypter + PG secrets table на Vault dynamic secrets. Интерфейс SecretsRepository уже готов к замене реализации.

  2. Automatic master-key rotation CLI. Утилита re-encrypt — читает все encrypted_with=v1 секреты, дешифрует v1-ключом, шифрует v2-ключом, обновляет запись. Текущий runbook описывает ручную процедуру.

  3. Per-supplier cooldown policies. Сейчас cooldown hardcoded (429→60s, 401/403→24h, 5xx→30s). Если поставщик имеет другую политику — нужна таблица supplier_network_policies с per-supplier overrides.

  4. Weighted / least-loaded PoolSelector. Если pool содержит ключи с разными rate limits, round-robin не оптимален. PoolSelector interface уже готов к замене реализации.

  5. ClickHouse audit projection из outbox. Outbox events credential.secret_accessed.v1 и другие — эмитятся. Агрегированная история доступа к credential’ам (кто, когда, сколько раз) — в ClickHouse проекции. Deferred до Phase 7+ аналитика.

  6. Cross-credential rate limiting. Сейчас бюджет per-credential. Supplier может иметь account-level rate limit, который суммирует все ключи аккаунта. Общий бюджет на supplier — credentials:bucket:<supplier_ref>:rpm. Deferred — нет известных поставщиков с таким поведением сейчас.


Ссылки

  • Spec: docs/superpowers/specs/2026-04-26-p4c-2-credentials-pg-migration-design.md
  • Runbook: docs/operations/credentials.md
  • ADR-0035 (proposal pipeline layering) — credentials — Layer 1.2 routing.
  • ADR-0030 (Clock + Money invariants) — Clock injection pattern применён в P4c-2 компонентах.
  • Runbook: docs/operations/discovery-visibility.md — Layer 1.2 cross-reference.