# Секреты интеграций — хранение и чтение

Модуль Integration Secrets — централизованное хранение секретов внешних сервисов (логины, пароли, API-ключи, токены) через единый механизм чтения. Для администраторов при настройке интеграций и переходе с устаревших таблиц `*Credentials`. Описывает архитектуру хранилища, Admin API, смарт-скрипты, интерфейс администратора и внешнее хранилище (Passwork).

## 1. Назначение и структура сущности

Модуль Integration Secrets обеспечивает централизованное хранение секретов внешних сервисов (логины, пароли, API-ключи, токены). Секрет идентифицируется по строковому ключу `serviceKey` и содержит произвольный payload — набор пар «поле → значение».

Все серверные компоненты читают секреты через единый механизм. Прямая работа с таблицами `*Credentials` / `*Settings` в новом коде не используется.

Каждый секрет содержит:

- `serviceKey` — уникальный строковый ключ-идентификатор секрета.
- `displayName` — отображаемое имя (обязательно, ограничено по длине).
- `payload` — набор пар «поле → значение» (`Login`, `Password`, `ApiKey`, `AccessToken` и др.). Значения хранятся в зашифрованном виде.
- `ownerUserId` — владелец секрета, если он заведён пользователем самостоятельно (self-service, см. §11). `NULL` — системный или административный секрет, заведённый через Admin API.

---

## 2. Настройка `UseIntegrationSecrets` и архитектура хранилища

Настройка `UseIntegrationSecrets` работает как переключатель режима чтения секретов.

| Значение | Поведение |
|---|---|
| `true` | Сначала ищет в `IntegrationSecrets`; если записи нет — обращается к устаревшим таблицам (`*Credentials` / `*Settings`) |
| `false` (по умолчанию) | Всегда обращается только к устаревшим таблицам |

Настраивается через интерфейс администратора → Настройки системы → Custom settings, ключ `UseIntegrationSecrets`.

Порядок чтения секрета:

```
Чтение секрета
    ├── сначала новое хранилище IntegrationSecrets (зашифрованный payload)
    └── затем устаревшие таблицы *Credentials / *Settings (если записи в новом нет)
```

При чтении из устаревших таблиц `serviceKey` разбирается как `{prefix}-{serviceId}`, и значение берётся из соответствующей таблицы. Чтение из устаревших таблиц в аудит не пишется.

---

## 3. Сервисы с готовой поддержкой нового механизма

Все 14 сервисов уже работают через единый механизм. При включении `UseIntegrationSecrets = true` они автоматически начинают читать из нового хранилища — изменений не требуется.

| Префикс `serviceKey` | Сервис |
|---|---|
| `ldap` | Active Directory / LDAP |
| `openldap` | OpenLDAP |
| `radius` | RADIUS |
| `diadoc` | Диадок (ЭДО) |
| `sbis` | СБИС (ЭДО) |
| `ews` | Exchange (EWS) |
| `oauth` | OAuth |
| `cryptopro-dss` | КриптоПро DSS |
| `azure-cognitive` | Azure Cognitive Services |
| `pt-sandbox` | PT Sandbox (антивирусная песочница) |
| `kontur-cloud` | Контур.Облако |
| `kontur-kedo` | Контур.КЭДО |
| `multifactor` | Multifactor |
| `jitsi` | Jitsi |

### 3.1. Почтовые ящики (`mailbox` / `servicemailbox`)

В отличие от 14 интеграционных сервисов выше, пароли почтовых ящиков (`mailbox-{MailBoxID}` — пользовательские из `EmailMailBoxes`; `servicemailbox-{Id}` — сервисные из `ServiceMailBoxes`) платформа пишет только в колонки этих таблиц — в `IntegrationSecrets` они не переносятся, и колонка здесь не временный откат, а рабочий путь:

- **Чтение** идёт стандартным маршрутом: новое хранилище `IntegrationSecrets` (если запись есть), иначе — колонка ящика. Способ расшифровки выбирается по формату самого значения: конверт `$v2$…` — примитив AES-GCM площадки, `VENC:` с Base64 — прежний CBC с маркером, немаркированное значение — прежний CBC без маркера. Немаркированное значение читается, пока площадка не объявила перешифровку завершённой (настройка `MailBoxesLegacyReEncryptionCompleted`); после этого ящик с остатком старого формата не подключается. В сертифицируемой сборке прежнего формата нет вовсе, поэтому не читается ни немаркированное значение, ни `VENC:`.
- **Запись** ведётся в колонки `EmailMailBoxes` / `ServiceMailBoxes`, шифрование — новым механизмом. Пока никто не запишет payload в `IntegrationSecrets` (через Admin API или скрипт), значение берётся из колонки, поэтому установка `UseIntegrationSecrets = true` для почтовых ящиков сама по себе ничего не меняет.
- **Поля payload** (если запись в `IntegrationSecrets` всё же создана): для пользовательского ящика — `Password`, `CalDavPassword`; для сервисного — `Password` (см. § 6).
- **Массовая перешифровка** уже сохранённых значений на новый механизм — утилита `MailBoxPasswordReEncryptionService`, запускаемая вручную (после раскатки кода на все экземпляры). Подробности — в [Хранение паролей почтовых ящиков](https://help.1forma.ru/domains/mail/admin.md). В сертифицированной сборке этой утилиты нет: перевод остатка выполняют на исходной площадке, на полной сборке, до формирования дампа.

---

## 4. Миграция конкретного сервиса

Кодовых изменений не требуется. Достаточно:

1. Включить `UseIntegrationSecrets = true` в custom settings (однократно для всего экземпляра).
2. Создать запись в `IntegrationSecrets` с нужным `serviceKey` через Admin API или смарт-скрипт:

```javascript
UTILS.set_secret_payload("diadoc-1", { Login: "user@domain.ru", Password: "secret" });
```

После этого платформа начнёт возвращать значения из нового хранилища. Устаревшие записи продолжают работать как резерв для остальных сервисов, у которых записи ещё нет.

---

## 5. Admin API

Маршрут: `api/admin/secrets` (только права администратора).

| Метод | URL | Назначение |
|---|---|---|
| `GET` | `api/admin/secrets` | Список секретов (без значений payload) |
| `GET` | `api/admin/secrets/{serviceKey}` | Карточка секрета (только `payloadKeys`) |
| `GET` | `api/admin/secrets/{serviceKey}/payload` | Расшифрованный payload (фиксируется в аудите) |
| `PUT` | `api/admin/secrets/{serviceKey}` | Создание / обновление |
| `DELETE` | `api/admin/secrets/{serviceKey}` | Деактивация (не физическое удаление) |
| `GET` | `api/admin/secrets/contracts` | Список зарегистрированных контрактов payload |
| `GET` | `api/admin/secrets/{serviceKey}/contract` | Контракт для конкретного ключа |
| `POST` | `api/admin/secrets/{serviceKey}/audit` | Аудит по секрету |
| `POST` | `api/admin/secrets/audit` | Общий аудит с фильтрацией |
| `POST` | `api/admin/secrets/{serviceKey}/migrate-to-external` | Перенос локального секрета в alias на внешнее хранилище (тело: `ExternalServiceId`, `ExternalPath`). Создаёт элемент во внешнем хранилище Passwork, обнуляет `PayloadEncrypted` в БД. Аудит: `MigratedToExternal` |
| `POST` | `api/admin/secrets/{serviceKey}/migrate-to-local` | Обратная миграция: payload вычитывается из внешнего хранилища, шифруется и сохраняется в БД 1F. Item во внешнем хранилище **не удаляется** (защита от потери данных — удаляет админ через UI хранилища). Аудит: `MigratedToLocal` |
| `GET`  | `api/admin/secrets/external/storages` | Список инстансов внешних хранилищ (для UI «куда мигрировать»). Берётся из `ServicesSettings` по `ExternalServiceType in { Passwork }` |
| `POST` | `api/admin/secrets/external/{serviceId:int}/passwork/refresh-tokens` | Ручное обновление access/refresh-токенов Passwork-инстанса |
| `POST` | `api/admin/secrets/external/{serviceId:int}/verify` | Проверочный запрос к внешнему хранилищу: статус `Ok` / `Warning` / `Critical`. Результат сохраняется для диагностики |
| `POST` | `api/admin/secrets/legacy/migrate-all` | Массовая миграция устаревших `*Credentials`-таблиц в единое `IntegrationSecrets`. Возвращает счётчики по сервисам |
| `POST` | `api/admin/secrets/legacy/migrate` | Миграция одного устаревшего сервиса (тело — `serviceKey` либо `serviceType` + `serviceId`) |

**Разделение метаданных и значений.** Списковые и карточечные методы возвращают только `payloadKeys` — имена полей payload без их значений. Расшифрованные значения доступны исключительно через отдельный маршрут (`/payload`). Каждый вызов этого endpoint фиксируется в аудите.

**Деактивация vs удаление.** `DELETE` выполняет деактивацию записи, а не физическое удаление. Это сохраняет историю в журнале аудита и позволяет отменить операцию при необходимости.

---

## 6. Контракты payload, валидация и аудит

Для зарезервированных префиксов зарегистрированы контракты — допустимые поля payload. При создании/обновлении секрет валидируется против контракта. Помимо префиксов из таблицы в п. 3 контракты зарегистрированы для почтовых ящиков (`mailbox`, `servicemailbox` — § 3.1), инстансов внешнего хранилища (`passwork` — § 9), self-service токенов (`token` — § 11) и внутреннего секрета ядра (`reportrender`).

Если `serviceKey` не относится ни к одному зарезервированному префиксу, контракт отсутствует и валидация по полям не выполняется.

При `PUT api/admin/secrets/{serviceKey}` платформа проверяет:

- `serviceKey` — должен соответствовать допустимому формату ключа.
- `displayName` — обязателен, ограничен по длине.
- `payload` — должен быть непустым объектом.
- Для зарезервированных префиксов дополнительно проверяется соответствие payload зарегистрированному контракту (допустимые поля).

**Регистр имён полей payload.** Имена полей сопоставляются без учёта регистра — и у секрета в локальном хранилище, и у payload из внешнего хранилища (§ 9): поле `AccessToken` читается по имени `accessToken`. При записи имя, совпавшее с именем свойства контракта без учёта регистра, сохраняется в канонической форме — так, как объявлено в контракте, — поэтому обновление контрактного поля именем в другом регистре не создаёт второе поле. Для ключа, которого в контракте нет, имя не канонизируется, и состав имён полей незарезервированного секрета при записи не меняется.

Если payload уже содержит несколько ключей, различающихся только регистром, чтение берёт значение последнего такого ключа в JSON и пишет неоднозначность в лог с именем секрета и именами ключей. Опираться на такие пары нельзя: при следующей записи любого поля секрета payload сохраняется уже схлопнутым, и одно из значений пропадает.

Записать payload с двумя ключами, различающимися только регистром, с v2.268 нельзя — запись отклоняется ошибкой ещё до сохранения, и в тексте ошибки названы оба ключа. Проверка выполняется для всех путей записи:

- через Admin API и форму администратора (создание, обновление, обновление одного поля);
- для секрета, которому контракт не назначен, в том числе для «Произвольного» (custom) секрета — отсутствие контракта проверку не выключает;
- из смарт-скриптов (Lua, JavaScript, C#) при записи во внешнее хранилище — до обращения к хранилищу;
- при миграции — как в локальное хранение, так и в alias на внешнее хранилище.

Если коллизий в payload нет, запись проходит как раньше: состав и порядок имён полей не меняются. Существующие секреты, в которых такая пара уже записана, эта проверка не чинит — но и не создаёт новых: ближайшая запись любого поля такого секрета отклоняется, пока пара не устранена вручную. Проверка опирается на исходный набор полей, а не на уже сведённый: для секрета во внешнем хранилище (alias) элемент читается без сведения имён, поэтому пара полей Passwork, различающихся только регистром, отклоняется и при обратной миграции в локальное хранилище, и при записи по alias (§ 9.3).

Каждое обращение к payload фиксируется с полями: `serviceKey`, тип действия, пользователь, момент, IP, источник (`adminApi` / `smartScript` / `userSelfService`). Чтение из устаревших таблиц в аудит **не попадает**.

**Пользователь в строке аудита бывает пустым.** Обращение к секрету без живого пользователя — анонимный вызов публикации, событийный скрипт, поднятый в анонимном запросе, любой другой запуск из такого контекста — записывается в журнал с пустым пользователем, и значение секрета при этом возвращается: это штатная запись, а не отказ. Колонка `PerformedByUserId` объявлена `INT NULL`, схема не меняется. Служебной учётной записью пустой пользователь не подменяется — иначе журнал утверждал бы, что секрет читал робот, хотя читал анонимный вызов; по той же причине не заводится фиктивный пользователь с отрицательным идентификатором.

**«Пользователя нет» и «пользователь неверный» — разные случаи.** Нормализация касается только первого: запись с несуществующим идентификатором пользователя, в том числе положительным, по-прежнему отклоняется внешним ключом на `PerformedByUserId`.


**Значения `IntegrationSecretAuditAction`:** `Read`, `Created`, `Updated`, `Deleted`, **`MigratedToExternal`** (payload перенесён во внешнее хранилище — alias, см. § 9), **`MigratedToLocal`** (обратная миграция alias → локальное хранение).

**Внутренние секреты ядра.** Контракт может быть помечен маркером внутреннего секрета — значения, которым обмениваются между собой компоненты самой платформы, а не платформа с внешним сервисом. Так помечен один ключ — `reportrender` (полный ключ равен префиксу: сервис отрисовки отчётов один). Единственное поле payload — `Secret`, ключ HMAC-SHA256, которым ядро подписывает запросы к сервису отрисовки отчётов. Сам ключ по сети не передаётся — им подписываются запросы. На стороне сервиса отрисовки то же значение задаётся его собственной настройкой `ReportingRender:Secret` (переменная окружения `REPORTING_RENDER_SECRET`).

Для внутреннего секрета действуют два ограничения:

- **Только локальное хранение** — зашифрованный `PayloadEncrypted` в БД 1F. Перевод записи в alias на внешнее хранилище (`migrate-to-external`, § 9) отклоняется: через прямой доступ к внешнему хранилищу значение читалось бы в обход запрета.
- **Закрыт для скриптовых функций работы с секретами** — см. § 7.

Ядро читает такой секрет внутренним маршрутом чтения, без записи в аудит. Администратор заводит и читает такой секрет обычным порядком через Admin API (§ 5) и Admin UI (§ 8), но перевести его во внешнее хранилище не может; обращение к payload фиксируется в аудите.

---

## 7. Смарт-скрипты

Чтение и запись секретов из смарт-скриптов (JavaScript и Lua):

```javascript
// JavaScript — чтение
var apiKey = UTILS.get_secret_value("sbis-1", "ApiKey");
var creds  = UTILS.get_secret_payload("sbis-1");

// JavaScript — запись
UTILS.set_secret_value("sbis-1", "ApiKey", newKey);
UTILS.set_secret_payload("sbis-1", { ApiKey: newKey, Login: "user" });
```

```lua
-- Lua — чтение
local apiKey = UTILS:get_secret_value("sbis-1", "ApiKey")
local creds  = UTILS:get_secret_payload("sbis-1")

-- Lua — запись
UTILS:set_secret_value("sbis-1", "ApiKey", newKey)
UTILS:set_secret_payload("sbis-1", { ApiKey = newKey, Login = "user" })
```

Если секрет или поле не найдены — возвращается `null`, исключение не бросается. Имя поля сопоставляется без учёта регистра (§ 6). Каждое успешное обращение к секрету фиксируется в аудите с источником `smartScript`.

**Скрипт без живого пользователя.** Анонимный вызов публикации и событийный скрипт, поднятый в анонимном запросе, читают секрет обычным порядком: значение возвращается, ошибка не возникает, а в строке аудита пользователь остаётся пустым (§ 6). Остальные правила прежние — `null`, если секрет или поле не найдены, и запрет на чтение внутренних секретов ядра.

**Скриптовые функции работы с секретами к внутренним секретам ядра не допускают.** Обращение через них к ключу, контракт которого помечен маркером внутреннего секрета (сейчас это `reportrender`, см. § 6), отклоняется ошибкой запрета доступа — в отличие от несуществующего секрета, который возвращает `null`. Запрет действует и на чтение (`get_secret_value`, `get_secret_payload`), и на запись (`set_secret_value`, `set_secret_payload`). Проверка выполняется на сервере и одинаково работает во всех скриптовых движках. На остальные секреты этот запрет не распространяется.

---

## 8. Интерфейс администратора (AdminSPA, v2.268+)

Маршрут: `/administration/secrets` (только права администратора). Страница для создания, просмотра и редактирования записей `IntegrationSecrets` через `api/admin/secrets` (см. § 5).

**Экраны:**

| Экран | URL | Назначение |
|---|---|---|
| Список секретов | `/administration/secrets` | Таблица с фильтрацией. Кнопки «Создать», «Общий журнал аудита» |
| Форма секрета | side-panel из списка | Создание / редактирование |
| Журнал по секрету | `/administration/secrets/{serviceKey}/audit` | Аудит операций конкретного секрета |
| Общий журнал | `/administration/secrets/audit` | Аудит по всем секретам с фильтрацией |

**Колонки списка:** `ServiceKey` · `displayName` · `Status` (чип: «Активен» / «Деактивирован») · `Payload` (chips, имена ключей) · `Created` · `Updated`.

**Контекстное меню строки:** Редактировать · Показать значения · Аудит · Деактивировать (см. § 5 — `DELETE` выполняет деактивацию, а не физическое удаление).

**Форма создания / редактирования:**

- `Service Key` — обязательный идентификатор. Регэксп: `[a-z0-9.-]+`, ≥2 символов. При редактировании поле disabled (изменение ключа делает аудит бессмысленным; для смены — пересоздание).
- `Название` (`displayName`) — опциональный, до 512 символов.
- `Payload` — динамический массив пар «ключ → значение», ключ до 128 символов, значение до 8192. Если для `ServiceKey` зарегистрирован контракт (§ 6), форма загружает его (`GET api/admin/secrets/{serviceKey}/contract`): имена полей берутся из контракта и не редактируются, строки нельзя добавить или удалить — заполняются только значения. Для ключа без контракта (ответ 404) набор полей произвольный и задаётся админом. Пока контракт загружается или если загрузка завершилась ошибкой, сохранение недоступно, а у ошибки есть кнопка повтора. Дубликат ключа — валидационная ошибка; ключи сравниваются без учёта регистра и пробелов по краям. Значения вводятся скрытыми, с показом по иконке-глазу. Автозаполнение браузера отключено.
- Можно добавлять/удалять пары через «Добавить поле».

**Просмотр payload (readonly):**

ReadOnly-таблица с колонками «Ключ», «Значение», «Действие». Значения по умолчанию замаскированы как `••••••••`; кнопка-глаз раскрывает конкретное поле — каждый раскрытие фиксируется в аудите как `Read` через `GET api/admin/secrets/{serviceKey}/payload`.

**Журнал аудита:**

| Фильтр | Значение |
|---|---|
| Период | datepicker (С / По) с точностью до минуты |
| Действие | чипы: `Created`, `Updated`, `Read`, `Deleted` |
| Пользователь | инициатор операции. У обращений без живого пользователя (анонимный вызов публикации, событие в анонимном запросе) поле пустое, и в фильтр по конкретному пользователю такие строки не попадают — искать их нужно по действию и периоду |
| Пагинация | стандартная (skip/take) |

Каждая строка журнала раскрывается до деталей операции (см. поля `AuditEntry` в § 5 и таблицу `IntegrationSecretAudit`).

**Связанная возможность:** конфигурация самих сервисов интеграций (URL, порты, флаги) управляется через отдельную форму `/administration/services` — см. [Сервисы — единая форма AdminSPA](https://help.1forma.ru/domains/integrations/admin.md#adminspa--единая-форма-сервисы-v2268). `ServiceType` в Сервисах соответствует префиксу `serviceKey` в Секретах.

---

## 9. Внешнее хранилище секретов и alias-записи

С v2.268 запись в `IntegrationSecrets` может хранить payload **не в самой 1F**, а во внешнем хранилище (Passwork v7 — единственный реализованный провайдер; механизм рассчитан на добавление других). Локальная строка в этом случае становится **alias** — указателем на элемент во внешнем хранилище.

### 9.1. Модель данных

Колонки, добавленные к таблице `IntegrationSecrets`:

| Колонка | Тип | Семантика |
|---|---|---|
| `ExternalServiceId` | int NULL (FK → `ServicesSettings(Id)`) | Инстанс внешнего хранилища, в котором лежит payload. NULL для локальных записей |
| `ExternalSecretId` | nvarchar(256) NULL | Идентификатор элемента во внешнем хранилище (для Passwork — `itemId`) |
| `PayloadEncrypted` | varbinary(max) NULL | **Стало nullable.** Для alias-записей всегда `NULL` — payload лежит снаружи |

**Инвариант:** либо `PayloadEncrypted IS NOT NULL` и `ExternalServiceId IS NULL` (локальный секрет), либо наоборот (alias). Гибридного режима нет.

### 9.2. Поток «миграция в alias» (`MigratedToExternal`)

Шаги переноса payload во внешнее хранилище:

1. Админ вызывает `POST api/admin/secrets/{serviceKey}/migrate-to-external` с телом `{ ExternalServiceId, ExternalPath }`.
2. Платформа расшифровывает текущий `PayloadEncrypted` и формирует элемент для внешнего хранилища.
3. Элемент создаётся в хранилище Passwork — в хранилище по умолчанию или по пути из `ExternalPath`.
4. После успешного ответа запись `IntegrationSecrets` обновляется атомарно: `PayloadEncrypted = NULL`, проставляются `ExternalServiceId` и `ExternalSecretId`.
5. Аудит: `MigratedToExternal`.

Запрос отклоняется, если у Passwork-инстанса выключена запись (`SupportsWrite = 0`) либо последняя проверка вернула статус `Critical`.

### 9.3. Обратная миграция, чтение alias-секретов и проверка

Поток «обратная миграция» (`MigratedToLocal`):

1. `POST api/admin/secrets/{serviceKey}/migrate-to-local` (без тела).
2. Платформа читает элемент из внешнего хранилища.
3. Payload шифруется штатным механизмом и пишется в `PayloadEncrypted`.
4. `ExternalServiceId` и `ExternalSecretId` обнуляются.
5. **Элемент во внешнем хранилище не удаляется** (защита от случайной потери — удаление остаётся ручной операцией в интерфейсе хранилища).
6. Аудит: `MigratedToLocal`.

Чтение и запись alias-секретов в реальном времени зависят от `ExternalServiceId`:
- `NULL` — payload расшифровывается из локального `PayloadEncrypted`.
- `NOT NULL` — значение читается из внешнего хранилища по `ExternalSecretId`.

Запись по alias (`UTILS.set_secret_value` из смарт-скриптов и интерфейса администратора) симметрична: значение обновляется во внешнем хранилище, локальный `PayloadEncrypted` остаётся `NULL`.

**Поля элемента, различающиеся только регистром.** Элемент во внешнем хранилище читается для путей записи «как есть» — без сведения имён: пара полей, различающихся только регистром (два кастомных поля между собой либо кастомное со стандартным, например `login` и `Login`), остаётся двумя записями и видна проверке. Если такая пара в элементе есть, с ошибкой, называющей оба имени, отклоняются:

- обратная миграция в локальное хранилище (`migrate-to-local`) — в локальную базу ничего не записывается;
- запись значения по alias (`set_secret_value`, `SetSecretValueAsync`, `set_external_secret_value`) — до обращения к внешнему хранилищу, элемент не меняется.

Обычное чтение alias-секрета прежнее: имена сводятся без учёта регистра, при совпадении побеждает последнее поле в порядке источника. Коллизию в элементе проверка не чинит — она только не даёт потерять поле молча; пока пара не устранена в самом элементе, миграция и запись по этому алиасу отклоняются.

Проверка / диагностика: `POST api/admin/secrets/external/{serviceId}/verify` запускает проверочный запрос к внешнему хранилищу и возвращает статус (`Ok` / `Warning` / `Critical`). Используется на странице «Сервисы» и в самодиагностике.

### 9.4. Миграция с устаревших таблиц

Параллельно с external-storage в v2.268 завершается миграция от множественных таблиц `*Credentials` (по сервису) к единому `IntegrationSecrets`:

- `POST api/admin/secrets/legacy/migrate-all` — пройти по всем устаревшим источникам секретов и перенести payload в `IntegrationSecrets` (с шифрованием). Возвращает счётчики по сервисам.
- `POST api/admin/secrets/legacy/migrate` — то же для одного сервиса (`serviceKey` либо `serviceType` + `serviceId`).

После миграции устаревшие таблицы остаются как резерв на чтение (на случай если кто-то ещё пишет в них напрямую), но основным источником становится `IntegrationSecrets`.

**Как читать отчёт перешифровки.** Прогон перешифровки значений по scope возвращает счётчики (`Scanned`, `AlreadyNew`, `Migrated`, `Skipped`, `Failed`), текстовые детали и два признака завершённости. `Incomplete` — обход оборвался и часть значений осталась в старом формате: прогон неуспешен, причину смотрят в деталях и scope повторяют. `NotApplicable` — таблицы этого scope на площадке нет, например не настроены LDAP, EWS или Контур: такой scope перехода не блокирует. Успех по scope — `Incomplete = false` и `Failed = 0`; один только `Failed = 0` об успехе не говорит, потому что оборванный обход тоже его не увеличивает. Повторный запуск идемпотентен: значения, уже переведённые в новый формат, пропускаются как `AlreadyNew`.

### 9.5. Legacy-резолверы устаревшей крипты (с 2.268.386)

Устаревшие таблицы `*Credentials` / `*Settings` шифровали значения старым механизмом: Rijndael-CBC с ключом из `Rfc2898DeriveBytes` и статической солью. Для миграции в `IntegrationSecrets` каждое такое хранилище получило собственный резолвер в `Valhalla.Secrets/Provider/Legacy/` — класс с суффиксом `LegacySecretResolver`, который читает старую запись, расшифровывает её унаследованным алгоритмом и передаёт открытый payload в `EncryptionService`, который шифрует его примитивом площадки: `VENC:` + Base64(CBC) в legacy-режиме (`UseLegacyEncryption = true`, значение по умолчанию) либо конверт `$v2$` после перехода площадки на AES-GCM. Результат записывается в `IntegrationSecrets.PayloadEncrypted`.

| Резолвер | Префикс `serviceKey` | Источник данных |
|---|---|---|
| `ADLegacySecretResolver` | `ldap` | Active Directory |
| `LDAPLegacySecretResolver` | `openldap` | OpenLDAP |
| `OAuthLegacySecretResolver` | `oauth` | OAuth-провайдеры |
| `RadiusLegacySecretResolver` | `radius` | RADIUS |
| `JitsiLegacySecretResolver` | `jitsi` | Jitsi |
| `S3LegacySecretResolver` | `s3` | S3-совместимые хранилища |
| `PassworkLegacySecretResolver` | `passwork` | Passwork (внешнее хранилище секретов) |
| `DiadocLegacySecretResolver` | `diadoc` | Диадок (ЭДО) |
| `KonturCloudLegacySecretResolver` | `kontur-cloud` | Контур.Облако |
| `KonturKEDOLegacySecretResolver` | `kontur-kedo` | Контур.КЭДО |
| `EWSLegacySecretResolver` | `ews` | Exchange Web Services |
| `MultifactorLegacySecretResolver` | `multifactor` | Multifactor |
| `PTSandboxLegacySecretResolver` | `pt-sandbox` | PT Sandbox |
| `SbisLegacySecretResolver` | `sbis` | СБИС (ЭДО) |
| `CryptoProDSSLegacySecretResolver` | `cryptopro-dss` | КриптоПро DSS |
| `AzureCognitiveLegacySecretResolver` | `azure-cognitive` | Azure Cognitive Services |

Резолверы вызываются из `POST api/admin/secrets/legacy/migrate` и `legacy/migrate-all` (см. § 9.4). Сама старая крипта в исходных таблицах не переписывается — резолвер работает read-only с legacy-источником и пишет уже новый формат только в `IntegrationSecrets`.

**Сертифицируемая сборка.** Прежнего формата в ней нет: примитив Rijndael-CBC в профиль не входит, поэтому значение с маркером `VENC:` при чтении даёт ошибку расшифровки. Резолверы при этом остаются в сборке, но унаследованную крипту им расшифровывать нечем: остаток прежнего формата в старых таблицах на контуре не восстанавливается. Перешифровка данных выполняется на полной сборке — до подготовки дампа для сертифицированного контура.

---

Перед записью payload во внешнее хранилище проверяется отсутствие ключей, различающихся только регистром: при наличии такой пары запись отклоняется до обращения к хранилищу. Проверка действует и на запись через alias из смарт-скриптов.

## 10. Отличие от мастер-ключа платформы

Integration Secrets — секреты внешних сервисов (API-ключи, логины, токены). Управляются через `ISecretsProvider` и Admin UI, видны администратору в интерфейсе.

Мастер-ключ платформы (`Settings.EncKey`) — корневой ключ шифрования самой платформы, которым шифруются в том числе payload-ы секретов интеграций. Настраивается через `appsettings.json` и `masterkey-tool`, не отображается в UI.

Ключ расшифровки лицензий — тоже секрет ядра, а не секрет интеграций: он хранится маскированными частями и выдаётся лизом, как мастер-ключ.

В сертифицируемой сборке (1F Certificate Edition) действует другое умолчание: мастер-ключ хранится не в базе, а в зашифрованном файле, ключ шифрования к нему — в отдельном файле; оба каталога находятся вне каталога приложения, чтобы переустановка не затирала единственную копию. Эти файлы обязательно включать в резервное копирование наравне с базой: без них зашифрованные данные восстановить нельзя.

---

## 11. Самостоятельный ввод секретов пользователем (self-service, v2.268+)

Помимо административного пути (§5), пользователь может завести собственные секреты в ограниченном неймспейсе — без прав администратора. Сценарий — личный токен интеграции (например, для внешнего API или бота), которым пользуется только сам сотрудник: не нужно просить администратора заводить общий секрет.

**Владение.** Колонка `OwnerUserId` (`IntegrationSecrets`, FK → `Users.UserId`, `ON DELETE NO ACTION`) — `NO ACTION`, а не `SET NULL`, т.к. на ту же таблицу уже ссылается `UpdatedByUserId` с `SET NULL`, и SQL Server не допускает несколько cascade-путей от одной таблицы. Индекс `IX_IntegrationSecrets_Owner` — по (`OwnerUserId`, `ServiceKey`). `NULL` в `OwnerUserId` — секрет системный/административный, заведён не через self-service.

**Разрешённый неймспейс и формат ключа.** Зарегистрировано два self-service контракта с разными префиксами `serviceKey`:

- `token` — ключ `token-u{uid}_{slug}`, payload валидируется по контракту `TokenSecretPayload`.
- `custom` — ключ `custom-u{uid}_{slug}`, `FreeForm` — контракта полей нет, payload произвольный.

`{slug}` — `^[a-z0-9._]{1,64}$`. Формирование ключа (`BuildServiceKey`) — fail-closed: попытка обратиться к чужому `uid` в ключе завершается `403 AccessDeniedException`, не `404`.

**User API.** Маршрут `api/user/secrets`, весь контроллер — `[Authorize]`.

| Метод | URL | Назначение |
|---|---|---|
| `GET` | `api/user/secrets` | Список собственных секретов пользователя |
| `GET` | `api/user/secrets/contracts` | Доступные self-service контракты (`token`, `custom`) |
| `GET` | `api/user/secrets/{serviceKey}` | Карточка собственного секрета |
| `POST` | `api/user/secrets` | Создание / обновление собственного секрета |
| `DELETE` | `api/user/secrets/{serviceKey}` | Деактивация собственного секрета |

Владение проверяется на каждой операции (`EnsureOwned`): секрет с чужим `OwnerUserId` или без владельца (системный) через User API недоступен.

**Аудит.** Источник фиксируется как `userSelfService` (см. §6), с IP-адресом обращения.

**Отличие от Admin API.** Пользователь видит и меняет только собственные секреты в разрешённом неймспейсе (`token-u{uid}_*`, `custom-u{uid}_*`); доступа к чужим и к системным/административным секретам (вне self-service неймспейса) через User API нет.
