# Хранилище (BYOB)

> **Статус:** Доступно с 2026-04-22.

Раздел **Хранилище** позволяет тенанту подключить собственный объектный сторадж (S3-бакет, S3-совместимый бакет, GCS-бакет или Azure Blob-контейнер) и хранить документы аппликантов / KYB-материалы / селфи в нём, а не в общем хранилище OneKYC. Эта возможность называется **BYOB — Bring Your Own Bucket**.

По умолчанию все тенанты используют общее хранилище OneKYC (режим `shared`). Подключение собственного бакета — опциональная функция, доступная без миграции данных и без даунтайма.

## Когда использовать BYOB [#когда-использовать-byob]

Подключение собственного бакета имеет смысл в следующих случаях:

- **Соответствие требованиям регуляторов / data residency.** Документы пользователей должны храниться в конкретной юрисдикции или в инфраструктуре, контролируемой тенантом.
- **Корпоративные политики безопасности.** Доступ к данным должен ограничиваться силами команды безопасности тенанта (own KMS keys, own IAM, own audit trail в CloudTrail / Cloud Audit Logs / Storage Analytics).
- **Контроль расходов.** Тенант оплачивает хранение напрямую своему облачному провайдеру.
- **Повышенный контроль за жизненным циклом.** Самостоятельное управление lifecycle-политиками (archival → Glacier / Coldline / Cool tier, retention).

Если ни один из пунктов не критичен, рекомендуется оставить общее хранилище — оно требует нулевой настройки и обслуживается командой OneKYC.

## Поддерживаемые провайдеры [#поддерживаемые-провайдеры]

| Провайдер | Значение `provider` | Аутентификация | Особенности |
|-----------|--------------------|----------------|-------------|
| AWS S3 | `aws_s3` | STS AssumeRole + ExternalID | Cross-account роль; OneKYC не хранит долговременные ключи AWS |
| S3-совместимый | `s3_compat` | Static AccessKey + SecretKey | Cloudflare R2, Backblaze B2, MinIO, Wasabi, IBM Cloud Object Storage |
| Google Cloud Storage | `gcs` | Service Account JSON | base64-encoded ключ Service Account |
| Azure Blob Storage | `azure_blob` | Account Key, ConnectionString или SAS Token | Любой из трёх способов |

Для AWS S3 используется паттерн **AssumeRole + ExternalID** — рекомендованный AWS способ предоставления стороннему сервису ограниченного доступа к ресурсам аккаунта без долговременных credentials. ExternalID защищает от атаки «confused deputy».

## Создание конфигурации [#создание-конфигурации]

### Через админ-панель [#через-админ-панель]

1. Перейдите в **Настройки → Хранилище**.
2. Нажмите **«Подключить собственный бакет»**.
3. Выберите тип провайдера и заполните параметры (см. ниже).
4. Сохраните черновик — конфигурация создаётся в статусе `pending` и пока не используется.
5. Запустите **валидацию** (см. раздел «Проверка конфигурации»).
6. После успешной валидации — **активируйте** конфигурацию.

### Через API [#через-api]

#### Пример для AWS S3 [#пример-для-aws-s3]

```http
POST /v1/admin/storage/configs HTTP/1.1
Authorization: Bearer <admin_jwt>
Content-Type: application/json

{
  "provider": "aws_s3",
  "bucket": "my-tenant-onekyc",
  "region": "eu-central-1",
  "aws_role_arn": "arn:aws:iam::123456789012:role/OneKYCStorage",
  "sse_mode": "sse_kms",
  "sse_kms_key_id": "arn:aws:kms:eu-central-1:123456789012:key/abcd-..."
}
```

В ответе вернётся объект конфигурации со статусом `pending` и сгенерированный `aws_external_id`. Этот ExternalID необходимо добавить в условие trust policy IAM-роли на стороне тенанта (см. ниже).

#### Пример для S3-совместимого хранилища (Cloudflare R2) [#пример-для-s3-совместимого-хранилища-cloudflare-r2]

```http
POST /v1/admin/storage/configs HTTP/1.1

{
  "provider": "s3_compat",
  "bucket": "tenant-onekyc-r2",
  "region": "auto",
  "endpoint": "https://<account-id>.r2.cloudflarestorage.com",
  "use_path_style": true,
  "access_key_id": "<R2 access key id>",
  "secret_key": "<R2 secret>"
}
```

#### Пример для Google Cloud Storage [#пример-для-google-cloud-storage]

```http
POST /v1/admin/storage/configs HTTP/1.1

{
  "provider": "gcs",
  "bucket": "tenant-onekyc-gcs",
  "service_account_json": "<base64-encoded SA JSON key file>"
}
```

#### Пример для Azure Blob [#пример-для-azure-blob]

```http
POST /v1/admin/storage/configs HTTP/1.1

{
  "provider": "azure_blob",
  "bucket": "onekyc-data",
  "endpoint": "<storage account name>",
  "azure_account_key": "<account key>"
}
```

> Поля `secret_key`, `service_account_json`, `azure_account_key`, `azure_connection_string`, `azure_sas_token` являются **write-only** — они шифруются и никогда не возвращаются ни одним из эндпоинтов.

### Требуемые IAM-разрешения (AWS) [#требуемые-iam-разрешения-aws]

Trust policy IAM-роли (на стороне тенанта):

```json
{
  "Version": "2012-10-17",
  "Statement": [{
    "Effect": "Allow",
    "Principal": { "AWS": "arn:aws:iam::<onekyc account id>:role/onekyc-byob-resolver" },
    "Action": "sts:AssumeRole",
    "Condition": {
      "StringEquals": { "sts:ExternalId": "<aws_external_id из ответа Create>" }
    }
  }]
}
```

Permission policy IAM-роли:

```json
{
  "Version": "2012-10-17",
  "Statement": [{
    "Effect": "Allow",
    "Action": [
      "s3:PutObject",
      "s3:GetObject",
      "s3:DeleteObject",
      "s3:GetObjectAcl",
      "s3:PutObjectAcl"
    ],
    "Resource": "arn:aws:s3:::<bucket>/*"
  }, {
    "Effect": "Allow",
    "Action": [
      "s3:ListBucket",
      "s3:GetBucketLocation"
    ],
    "Resource": "arn:aws:s3:::<bucket>"
  }]
}
```

Если включён режим SSE-KMS (`sse_mode = "sse_kms"`), также потребуется политика на сам KMS-ключ:

```json
{
  "Effect": "Allow",
  "Action": [
    "kms:Encrypt",
    "kms:Decrypt",
    "kms:GenerateDataKey"
  ],
  "Resource": "<sse_kms_key_id>"
}
```

## Проверка конфигурации (Validation Probe) [#проверка-конфигурации-validation-probe]

Перед активацией конфигурация проходит автоматический набор из 14 проверок (10 базовых + 4 production-readiness):

```http
POST /v1/admin/storage/configs/{id}/validate
```

| # | Проверка | Что проверяет |
|---|----------|---------------|
| 1 | `head_bucket` | Бакет существует и предоставлены права `s3:ListBucket` |
| 2 | `put_probe` | Загрузка пробного объекта (`__probe__/health-*.txt`) |
| 3 | `get_probe` | Чтение того же объекта |
| 4 | `delete_probe` | Удаление пробного объекта |
| 5 | `presign_get` | Генерация подписанного GET URL (для отдачи фронтенду) |
| 6 | `presign_post` | Генерация подписанного POST URL (для прямой загрузки с устройства аппликанта) |
| 7 | `list_objects` | Листинг объектов по префиксу |
| 8 | `category_dispatch` | Корректное разделение по категориям (documents / selfies / ...) |
| 9 | `health_quick` | Базовый health-check провайдера |
| 10 | `probe_cleanup` | Удаление всех созданных пробных объектов |
| 11 | `public_access` | Анонимное чтение должно быть запрещено (Block Public Access) |
| 12 | `clock_skew` | Расхождение часов сервера и клиента не превышает 60 сек |
| 13 | `multipart_upload` | Корректная работа multipart-загрузки больших файлов |
| 14 | `copy_object` | Поддержка серверного копирования (требуется для миграции shared → tenant) |

Дополнительный 15-й probe (`sse_enforcement`) запускается при `sse_mode = "sse_kms"` и подсказывает bucket policy для запрета загрузок без шифрования.

Ответ содержит per-probe детализацию:

```json
{
  "data": {
    "report": {
      "results": [
        { "name": "head_bucket", "passed": true, "duration_ns": 145000000 },
        { "name": "put_probe", "passed": false,
          "error": "AccessDenied",
          "action_hint": "Grant s3:PutObject on bucket prefix" }
      ],
      "all_passed": false
    },
    "all_passed": false
  }
}
```

Для каждой проваленной проверки возвращается `action_hint` — конкретная подсказка («добавьте `s3:PutObject`», «снимите Public Access»). Validation возвращает `200 OK` и при провале части проб (с `all_passed=false`) — это позволяет UI показать чек-лист, не парся error envelope.

## Активация [#активация]

После успешной валидации конфигурация переводится в активное состояние:

```http
POST /v1/admin/storage/configs/{id}/activate
X-Confirm-Tenant: <tenant UUID>
```

Заголовок `X-Confirm-Tenant` обязателен — он защищает от случайных активаций. Значение должно совпадать с UUID текущего тенанта.

После активации:

- **Все новые загрузки** (документы, селфи и т. д.) идут сразу в подключённый бакет тенанта.
- **Исторические данные** в общем хранилище копируются в фоне.

Действие фиксируется в журнале аудита (`tenant.updated` + `action=storage_config_activated`).

## Миграция исторических данных [#миграция-исторических-данных]

Активация сразу переключает новые загрузки в подключённый бакет. Файлы, загруженные ранее, остаются доступными, пока OneKYC копирует их в бакет тенанта.

### Параметры [#параметры]

- **Rate-limit:** 10 МБ/с на тенанта по умолчанию.
- **Проверка целостности:** скопированные объекты проверяются после загрузки.
- **Grace period:** 30 дней между завершением миграции и удалением копий из общего хранилища.

### Состояния задачи миграции [#состояния-задачи-миграции]

| Статус | Описание |
|--------|----------|
| `pending` | Задача создана, ожидает запуска |
| `running` | Идёт копирование |
| `paused` | Приостановлено (только через супер-админа) |
| `completed` | Все объекты успешно скопированы |
| `failed` | Достигнут лимит retry; см. `last_error` |
| `cancelled` | Тенант инициировал Disable во время миграции |

### Запрос статуса миграции [#запрос-статуса-миграции]

```http
GET /v1/admin/storage/migration/current
```

Ответ содержит счётчики `total_objects` / `copied_objects` / `failed_objects`, прогресс в байтах, `started_at`, `completed_at`, последнюю ошибку.

## Мониторинг здоровья [#мониторинг-здоровья]

```http
GET /v1/admin/storage/health
```

Возвращает последний снимок здоровья хранилища:

| Поле | Значение |
|------|----------|
| `status` | `healthy` / `degraded` / `unhealthy` |
| `last_check_at` | Время последней проверки |
| `last_success_at` | Время последней успешной проверки |
| `consecutive_failures` | Количество подряд неуспешных проверок |
| `last_error` / `last_error_code` | Последняя ошибка |
| `alert_sent_at` | Когда отправлено уведомление о деградации |

Health-check выполняется автоматически в фоне (легковесный probe). При нескольких подряд неуспешных проверках администратор тенанта получает email-уведомление.

## Ротация credentials [#ротация-credentials]

Если ключи скомпрометированы или подходит срок плановой ротации:

```http
POST /v1/admin/storage/configs/{id}/rotate
Content-Type: application/json

{
  "access_key_id": "<новый AccessKey>",
  "secret_key": "<новый SecretKey>"
}
```

Для AWS S3 обычно ротируются `aws_role_arn` и пересоздаётся ExternalID; для GCS — новый `service_account_json`; для Azure — новый Account Key / SAS Token.

После ротации статус сбрасывается на `pending` — необходимо снова запустить **Validate**, и только после успешной валидации конфигурация снова становится активной (`Activate`).

## Отключение [#отключение]

Если тенант хочет вернуться к общему хранилищу:

```http
POST /v1/admin/storage/disable
X-Confirm-Tenant: <tenant UUID>
Content-Type: application/json

{
  "reason": "Описание причины (минимум 10 символов)"
}
```

Эффект:

- Конфигурация переводится в статус `disabled`.
- Все **новые** загрузки снова идут в общее хранилище OneKYC.
- Незавершённая миграция отменяется (статус `cancelled`).
- **Файлы, уже записанные в бакет тенанта, НЕ удаляются** — тенант сохраняет полный контроль над своим бакетом и может удалить их самостоятельно.
- Эти файлы остаются доступными, пока конфигурация не будет удалена.

Поле `reason` обязательно (минимум 10 символов) — оно фиксируется в журнале аудита для последующего разбора инцидентов.

Чтобы снова подключить собственный бакет, потребуется создать новую конфигурацию с нуля.

## Удаление конфигурации [#удаление-конфигурации]

```http
DELETE /v1/admin/storage/configs/{id}
```

Удалить можно только конфигурацию в статусе `pending`, `validated` или `failed`. Активную конфигурацию необходимо предварительно перевести в `disabled` через эндпоинт `/disable`.

## Шифрование credentials [#шифрование-credentials]

Все секреты (`secret_key`, `service_account_json`, ключи Azure) хранятся в БД в зашифрованном виде:

- Алгоритм — AES-256-GCM (envelope encryption).
- DEK (Data Encryption Key) выводится из master-ключа OneKYC через HKDF-SHA256 с UUID тенанта в качестве salt и version-tagged info-строки.
- Master-ключ хранится в переменной окружения `STORAGE_MASTER_KEY` (не пересекается с `ENCRYPTION_KEY`).
- При смене формата шифрования старые ciphertexts продолжают расшифровываться через `credentials_version`, новые шифруются текущей версией.

Секреты невозможно прочитать из API — даже у супер-админа OneKYC нет интерфейса для их извлечения. В случае утраты — единственный путь восстановления — ротация (`/rotate`) с новыми credentials.

## Часто задаваемые вопросы [#часто-задаваемые-вопросы]

### Сколько стоит BYOB? [#сколько-стоит-byob]

Со стороны OneKYC — бесплатно (опция доступна на любом тарифе). Расходы на хранение и трафик оплачивает тенант напрямую своему облачному провайдеру (AWS, GCP, Cloudflare и т. д.).

### Увеличится ли latency загрузок? [#увеличится-ли-latency-загрузок]

Незначительно. OneKYC использует presigned URL — браузер аппликанта загружает файл напрямую в бакет тенанта, минуя API OneKYC. Latency определяется выбранным регионом бакета (рекомендуется выбрать регион, ближайший к большинству пользователей).

### Что произойдёт при недоступности AWS / KMS? [#что-произойдёт-при-недоступности-aws--kms]

Загрузки на тот период вернутся с ошибкой 5xx; OneKYC не «откатывается» автоматически на shared (это нарушило бы compliance-инвариант). При длительной недоступности администратор тенанта может выполнить **Disable** — и записи снова пойдут в общее хранилище.

Health-check фиксирует деградацию и отправляет email-уведомление при нескольких подряд неуспешных проверках.

### Можно ли иметь две активные конфигурации одновременно? [#можно-ли-иметь-две-активные-конфигурации-одновременно]

Нет. На тенанта одновременно может быть активна только одна конфигурация. Чтобы переключиться на другой бакет, текущую активную конфигурацию нужно сначала `Disable`, затем создать новую.

## Диагностика [#диагностика]

| Проверка | Типичная причина провала | Действие |
|----------|--------------------------|----------|
| `head_bucket` | Бакет не существует / нет `s3:ListBucket` | Проверьте имя бакета, добавьте права |
| `put_probe` | Нет `s3:PutObject` | Проверьте permission policy |
| `get_probe` / `delete_probe` | Нет `s3:GetObject` / `s3:DeleteObject` | Добавьте права |
| `presign_get` | Endpoint неверно сконфигурирован | Проверьте `endpoint` для S3-compat |
| `public_access` | Бакет отвечает на анонимные запросы | Включите Block Public Access (AWS) / `publicAccessPrevention=enforced` (GCS) / `allowBlobPublicAccess=false` (Azure) |
| `clock_skew` | Расхождение часов превышает 60 сек | Настройте NTP на стороне сервера-источника |
| `multipart_upload` | Провайдер не поддерживает multipart или нет прав на сегменты | Проверьте поддержку multipart у провайдера |
| `copy_object` | Нет `s3:CopyObject` или копирование между бакетами заблокировано | Добавьте права `s3:GetObject` + `s3:PutObject` на исходный и целевой бакет |

Все ошибки также фиксируются в журнале аудита (`storage_config_validated` / `storage_config_activated` / `storage_config_disabled`).

## Устранение неполадок [#устранение-неполадок]

### Прогресс показывает `0 / 0 N с ошибками` [#прогресс-показывает-0--0-n-с-ошибками]

Если на свежей активации всё ещё `0 / 0` — значит в общем хранилище нет файлов для миграции, новые загрузки появятся по мере работы. Это ожидаемое состояние для нового тенанта.

### Статус `Завершено`, но строка `N с ошибками` [#статус-завершено-но-строка-n-с-ошибками]

Если часть файлов не удалось скопировать, задача переходит в `failed`. OneKYC автоматически повторяет попытку, а на панели появляется кнопка **Повторить миграцию** для немедленного ручного перезапуска.

Если задача остаётся `failed` после нескольких попыток — посмотрите текст `last_error` на панели. Самые частые причины:

- `signature does not match` — в credentials есть пробелы / переводы строк, либо `region` не совпадает с сегментом URL endpoint. Сделайте rotate, вставьте ключи БЕЗ хвостовых переводов строки.
- `permission denied` — bucket policy запрещает `s3:PutObject` или у IAM-роли нет inline-полиса. Проверьте политику через мастер.
- `bucket not found` — бакет был удалён или переименован вне OneKYC.

### Расшифровка кодов здоровья [#расшифровка-кодов-здоровья]

Health-панель показывает короткий код ошибки и локализованную подсказку. Полная таблица:

| Код | Значение | Действие |
|-----|----------|----------|
| `CONFIG_NOT_FOUND` | Строка конфига отсутствует в БД | Запустите **Activate** заново |
| `CREDENTIALS_INVALID` | Драйвер отклонил credentials | **Rotate** со свежими ключами |
| `EXTERNAL_ID_MISMATCH` | AWS AssumeRole external ID не совпадает | Вставьте External ID в trust policy IAM-роли |
| `TENANT_STORAGE_UNAVAILABLE` | Провайдер временно недоступен | Подождите следующей проверки; если повторяется — обратитесь к провайдеру |
| `PERMISSION_DENIED` | Bucket policy блокирует операцию | Скорректируйте bucket / IAM policy |
| `BUCKET_NOT_FOUND` | Бакет удалён вне OneKYC | Создайте бакет заново ИЛИ **Disable** + новый конфиг |
| `SIGNATURE_MISMATCH` | Расхождение региона или пробелы в ключах | Сделайте rotate, исправьте поле региона |
| `HEALTH_CHECK_FAILED` | Общий случай | Смотрите `last_error` для деталей |

### Path-style адресация [#path-style-адресация]

S3-совместимые провайдеры используют разные способы адресации. Одни бакеты работают через **path-style** URL (`host/bucket/key`), другие через **virtual-hosted** URL (`bucket.host/key`).

OneKYC сохраняет выбранное значение и использует его как есть при создании storage provider. Следующий шаг **Validate** проверяет бакет именно в этом режиме; если провайдер отклоняет способ адресации, вернитесь назад, переключите **Use path-style** и запустите validation снова.

См. также: [Журнал аудита](/ru/admin/audit), [Настройки тенанта](/ru/admin/settings).
