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
-
Нет rotation без перезапуска. Секрет живёт в env-var, смена ключа поставщика = change env + restart. Downtime даже при rolling deploy.
-
Нет rate budget. StubRouter выдавал любую credential без учёта текущей нагрузки на неё. Supplier 429 обнаруживался только реактивно (connector получал 429, логировал, следующий запрос шёл на ту же перегруженную credential).
-
Нет health-aware fail-over. При 401/403 (credential отозван) или серии 5xx (supplier временно нестабилен) StubRouter продолжал отдавать ту же credential, раз за разом получая ошибку.
-
Секреты в env-переменных. Env-vars видны во всех инструментах ops:
docker inspect,/proc/<pid>/environ, лог-агрегатор при старте, CI/CD pipeline secrets dump. Не удовлетворяет базовой операционной гигиене. -
Нет операторского CRUD. Добавить credential = изменить deployment манифест, сделать PR, дождаться merge + deploy. Оперативное реагирование (supplier предоставил новый API key) — невозможно.
-
Нет audit trail. Кто изменил, когда, зачем — нет. Outbox-events не эмитятся при изменении env-vars.
-
Один 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-значений:
| Значение | Описание |
|---|---|
CustomerProposal | Proposal-запрос от имени customer’а |
CatalogSync | Фоновая синхронизация каталога |
PriceRefresh | Фоновое обновление цен |
StockRefresh | Фоновое обновление остатков |
DiagnosticProbe | Health/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 имеет
weightint) — для разных 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 header | Clamp(retryAfterValue, 10s, 1h) |
| 429 без Retry-After | 60s |
| 401 / 403 | 24h (effectively manual unlock) |
| 5xx / timeout | 30s |
| 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_admin | SELECT, INSERT, UPDATE, DELETE | НЕТ |
credentials_secrets_reader | SELECT (id only) | SELECT |
credentials_secrets_writer | НЕТ (через credentials_admin connection) | INSERT, UPDATE, DELETE |
Правила применяются через 3 независимых PG connection pool’а в fx wiring:
AdminPool—credentials_adminuser (metadata CRUD + admin HTTP).SecretsReaderPool—credentials_secrets_readeruser (hot-path Pick).SecretsWriterPool—credentials_secrets_writeruser (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; это не новая зависимость.
- Redis INCRBY (RateBudget.TryAcquire). Если Redis недоступен —
-
Сложность процедуры seed-credentials. One-time migration env→DB требует ручного ввода секретов (или подготовки secrets-file). Нет автоматического импорта из env при старте.
Нейтральные последствия
-
LegacyCredentialRouteradapter вводит временный слой абстракции. Не критично — adapter тонкий, unit-tested. -
Env var
INGESTION_EXTRA_CREDENTIALSполностью удалена из production кода. Seed-credentials CLI использует её как optional input для migration. -
OrderPlacementusage зарезервирован в 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_withTEXT).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.go—CredentialUsageenum (6 значений +IsValid).rate_limit.go—RateLimit { RequestsPerMinute int }VO.descriptor.go—CredentialDescriptor(output Pool.Pick с plaintext secret).pool.go—CredentialPoolinterface +PickRequest+ domain errors.repository.go— 6 collaborator interfaces:Repository,SecretsRepository,HealthTracker,RateBudget,PoolSelector,Encrypter.scope_resolver.go—resolveScope(intent, customerID) CredentialScopepure function.
Infrastructure (backend/internal/core/credentials/infra/):
postgres/credential_repository.go— PG-backedRepository.postgres/secrets_repository.go— PG-backedSecretsRepository.crypto/aes_encrypter.go— AES-256-GCM versioned master-key.redis/health_tracker.go—credentials: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.go—CredentialPoolService(read path).credential_service.go— write path (Create/Update/Rotate/Transition/Reset/Delete).legacy_router.go—LegacyCredentialRouteradapter.dto.go— application-level DTOs.
HTTP layer (backend/internal/core/credentials/api/http/):
handler.go— 8 admin endpoints.dto.go— HTTP DTOs (nosecret_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
-
Vault migration. Когда compliance или масштаб потребуют, заменить
AESEncrypter+ PG secrets table на Vault dynamic secrets. ИнтерфейсSecretsRepositoryуже готов к замене реализации. -
Automatic master-key rotation CLI. Утилита
re-encrypt— читает всеencrypted_with=v1секреты, дешифрует v1-ключом, шифрует v2-ключом, обновляет запись. Текущий runbook описывает ручную процедуру. -
Per-supplier cooldown policies. Сейчас cooldown hardcoded (429→60s, 401/403→24h, 5xx→30s). Если поставщик имеет другую политику — нужна таблица
supplier_network_policiesс per-supplier overrides. -
Weighted / least-loaded PoolSelector. Если pool содержит ключи с разными rate limits, round-robin не оптимален.
PoolSelectorinterface уже готов к замене реализации. -
ClickHouse audit projection из outbox. Outbox events
credential.secret_accessed.v1и другие — эмитятся. Агрегированная история доступа к credential’ам (кто, когда, сколько раз) — в ClickHouse проекции. Deferred до Phase 7+ аналитика. -
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.