Сценарий: подключение клиентской credential поставщика

NOTE

Статус: Target design. Документ описывает целевую доменную модель. Соответствующий код реализован частично (см. backend/internal/core/) или пока не начат. Правила маркировки — в 50-processes/documentation-standard.md.

Триггер

Customer добавляет учётную запись поставщика через UI кабинета.

Участники

BCРоль
CustomerOwner aggregate; ссылается на credentials.
CredentialsOwner процесса. Шифрование, validation, fingerprint, group merge.
IngestionПодписан на SupplierCredentialActivated → запускает discovery.
PricingInvalidate cache при изменениях.
VisibilityПрименяет дефолты для нового context.
External (KMS)Через MasterKeyProvider.

Sequence diagram

sequenceDiagram
    autonumber
    participant U as 🟫 Customer
    participant API as Customer Cabinet API
    participant CR as Credentials
    participant KMS as 🌐 KMS / Vault
    participant CON as Connector (Ingestion)
    participant SUP as 🌐 Supplier API
    participant ING as Ingestion
    participant P as Pricing

    U->>API: POST /credentials {supplier=etm, schema=login_password, login, password}
    API->>CR: 🟦 AddCredential
    CR-->>CR: 🟧 SupplierCredentialProvided{status=draft}
    Note over CR: 🟪 Encrypt at rest
    CR->>KMS: get DEK (per-customer)
    KMS-->>CR: DEK
    CR->>CR: encrypt secret fields
    CR-->>CR: 🟧 CredentialEncryptedAtRest
    CR-->>CR: 🟧 status=validating
    CR->>CON: ValidateCredential
    CON->>SUP: test login + minimal request
    alt success
        SUP-->>CON: 200
        CON-->>CR: 🟧 SupplierCredentialValidated
        CR->>CR: 🟦 ComputeFingerprint
        CR->>CR: lookup SupplierCredentialGroup by fingerprint
        alt совпал, autoMerge условия выполнены
            CR-->>CR: 🟧 SupplierCredentialGroupMerged
            Note over CR: используется existing primary credential для опросов
        else совпал partial / условия не выполнены
            CR-->>CR: 🟧 CredentialGroupCandidateOpened (moderation)
        else fingerprint новый
            CR-->>CR: 🟧 SupplierCredentialGroupCreated{primary=this}
        end
        CR-->>CR: 🟧 SupplierCredentialActivated
        CR-->>ING: 🟧 SupplierCredentialActivated
        CR-->>P: 🟧 SupplierCredentialActivated
        ING->>ING: 🟦 EnqueueEnrichmentJob{kind=discovery}
        P-->>P: invalidate pricing cache for this customer
    else auth_failed
        SUP-->>CON: 401
        CON-->>CR: 🟧 SupplierCredentialFailing
        Note over CR: notify customer, badge в кабинете
    end
    API-->>U: 200 + status

Шаги

  1. Customer вводит данные — UI рендерится по JSON Schema схемы поставщика; поля с secret: true помечены.
  2. API → AddCredential — Credentials BC создаёт SupplierCredential{status=draft}.
  3. Encrypt at rest (Policy):
    • Получить per-customer DEK (через MasterKeyProvider).
    • Шифровать secret-поля.
    • Сохранить в PG.
    • CredentialEncryptedAtRest.
  4. Validate — Connector выполняет provider contract validation suite (ADR-0023 + ADR-0024):
    • auth success (login → получен session);
    • minimal data fetch (для ETM: GET /goods/{any_etm_code}/price?type=etm — проверяет и data rate limit, и корректность session);
    • Capabilities матчит то, что поставщик реально отдаёт (если декларировано Prices=true, price endpoint должен ответить). Любой fail → ConnectorError → не меняем status:
    • AuthRejectedSupplierCredentialFailing + notification клиенту.
    • SessionExpired на первом же запросе — bug connector’а → alert, credential остаётся validating для ручного разбора.
    • Success suite → SupplierCredentialValidated.
  5. Compute fingerprint:
    • fingerprint = sha256(supplier_id + auth_schema + canonical_identity_field(payload)).
    • canonical_identity_field:
      • login_password → normalized login (lowercase + trim).
      • api_key → hash или prefix.
      • oauth2 → provider-specific user id (получается при validation).
      • bearer_token → hash.
  6. Group lookup:
    • SELECT group WHERE fingerprint = ?.
    • Найдена + auto-merge условия выполнены (см. ниже) → SupplierCredentialGroupMerged.
    • Найдена + partial / условия не окCredentialGroupCandidateOpened → moderation queue.
    • Не найденаSupplierCredentialGroupCreated{primary=this credential}.
  7. ActivateSupplierCredentialActivated. Публикуется как integration event.
  8. Downstream:
    • Ingestion: запуск EnrichmentJob{kind=discovery} с этой credential — что новое поставщик может предложить.
    • Ingestion (если connector имеет dictionary_exports capability): EnrichmentJob{kind=client_sku_map_import} — импорт cross-reference клиентских SKU. Для ETM — через SgGds async-report с последующим парсингом (id, cli_code, article, brand_code).
    • Pricing: invalidate cache для customer. Будущие запросы будут использовать эту credential.
  9. UX: customer видит status в кабинете; история использования (запросов от его имени).

Auto-merge условия (для group)

Все должны выполниться:

  1. Полное совпадение (supplier_id, auth_schema, fingerprint).
  2. Обе credentials — active (или одна validating, только что создана).
  3. Customer’ы — разные (для одного customer объединение бессмысленно).
  4. Существующая primary credential успешно валидировалась последние 7 дней.

При partial совпадении (тот же логин, разный пароль; тот же API key, разный key_id) — НЕ auto-merge.

Decision points

  • Customer вводит дубль своей же credential — отказ с сообщением «уже есть».
  • Fingerprint совпадает с другим customer, но schema validation failed для этого customer’а — credential остаётся failing, в группу не входит.
  • Customer пытается revoke single credential, а это primary в группе — group переключается на следующего active member; если их нет — group → archived.
  • Customer revoke во время активного pricing-запроса — request завершается с пометкой customer_credential_revoked (fallback на system).

Edge cases

СлучайПоведение
KMS недоступен в момент создания503 + retry; credential не создаётся (без шифрования — нельзя).
Connector validation timeoutSupplierCredentialFailing + notification. Customer может re-validate.
Customer вводит OAuth credential — провайдер просит consentRedirect к provider’у; callback завершает enrichment payload.
Поставщик rotate’ит pass on his sideAuth fail → SupplierCredentialFailing. Customer уведомлён, обновляет в кабинете.
Группа имеет 5 members, primary стал failingОдин из members (next active) становится primary. Остальные продолжают видеть observations с переводом ownership.
Holding clients имеют одну учётку (Альфа-Север + Альфа-Юг на ETM)Обе credentials → одна group; physical опрос ETM — один; обе видят observations; pricing rules могут различаться.

Инварианты сценария

  1. Plaintext секрета никогда не попадает в логи / errors / responses.
  2. После SupplierCredentialProvided без CredentialEncryptedAtRest — credential нельзя активировать (state machine).
  3. Privacy: customer A не видит, что customer B — member той же group (administrative дедупликация прозрачна).
  4. Discovery job после activate — kind=discovery, чтобы не дублировать observations при group-merge.
  5. Любая DecryptForUse логируется в credential_decrypt_audit с reason.

Метрики и observability

  • credentials_added_total{supplier, scope}.
  • credentials_validation_latency_seconds{supplier}.
  • credentials_validation_failures_total{supplier, reason}.
  • credential_group_merges_total{supplier}.
  • credential_decrypts_total{reason} — для аудита.

Связанные файлы