> For the complete documentation index, see [llms.txt](https://finext.gitbook.io/one-kyc/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://finext.gitbook.io/one-kyc/admin/tenant-storage.md).

# Tenant storage (BYOB)

> **Status:** Generally available since 2026-04-22.

The **Storage** section lets a tenant connect its own object storage and host applicant documents, KYB materials, and selfies there instead of in OneKYC's shared storage. Supported providers are S3, S3-compatible buckets, GCS, and Azure Blob containers. This capability is called **BYOB — bring-your-own-bucket**.

By default, every tenant uses OneKYC's shared storage (mode `shared`). Connecting a tenant-owned bucket is an opt-in feature, available without data migration and without downtime.

## When to use BYOB

Connecting your own bucket makes sense in the following cases:

* **Regulatory / data-residency compliance.** User documents must be stored in a specific jurisdiction or in infrastructure controlled by the tenant.
* **Corporate security policies.** Data access must be limited to the tenant's security team. The tenant brings its own KMS keys, its own IAM, and its own audit trail (CloudTrail / Cloud Audit Logs / Storage Analytics).
* **Cost control.** The tenant pays for storage directly to its cloud provider.
* **Greater lifecycle control.** Self-managed lifecycle policies (archival → Glacier / Coldline / Cool tier, retention).

If none of these are critical, keep shared storage — it requires zero setup and is operated by the OneKYC team.

## Supported providers

| Provider             | `provider` value | Authentication                              | Notes                                                                |
| -------------------- | ---------------- | ------------------------------------------- | -------------------------------------------------------------------- |
| AWS S3               | `aws_s3`         | STS AssumeRole + ExternalID                 | Cross-account role; OneKYC does not store long-term AWS keys         |
| S3-compatible        | `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 key                                   |
| Azure Blob Storage   | `azure_blob`     | Account Key, ConnectionString, or SAS Token | Any of the three                                                     |

For AWS S3, the **AssumeRole + ExternalID** pattern is used — AWS's recommended way to grant a third-party service limited access to account resources without long-term credentials. The ExternalID protects against the "confused deputy" attack.

## Creating a configuration

### Through the admin panel

1. Go to **Settings → Storage**.
2. Click **"Connect own bucket"**.
3. Pick the provider type and fill in the parameters (see below).
4. Save the draft — the configuration is created in `pending` status and is not yet used.
5. Run **validation** (see "Configuration validation" below).
6. After validation succeeds — **activate** the configuration.

### Through the API

#### Example for 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-..."
}
```

The response returns the configuration object in `pending` status and a generated `aws_external_id`. Add this ExternalID to the trust-policy condition of the IAM role on the tenant side (see below).

#### Example for an S3-compatible store (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>"
}
```

#### Example for 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>"
}
```

#### Example for 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>"
}
```

> The fields `secret_key`, `service_account_json`, `azure_account_key`, `azure_connection_string`, `azure_sas_token` are **write-only** — they are encrypted and never returned by any endpoint.

### Required IAM permissions (AWS)

Trust policy of the IAM role (on the tenant side):

```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 from Create response>" }
    }
  }]
}
```

Permission policy of the IAM role:

```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>"
  }]
}
```

If SSE-KMS mode is enabled (`sse_mode = "sse_kms"`), a KMS-key policy is also required:

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

## Configuration validation (validation probe)

Before activation the configuration goes through 14 automated checks (10 baseline + 4 production-readiness):

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

| #  | Check               | What it verifies                                                                    |
| -- | ------------------- | ----------------------------------------------------------------------------------- |
| 1  | `head_bucket`       | The bucket exists and `s3:ListBucket` is granted                                    |
| 2  | `put_probe`         | A probe object is uploaded (`__probe__/health-*.txt`)                               |
| 3  | `get_probe`         | The same object is read back                                                        |
| 4  | `delete_probe`      | The probe object is deleted                                                         |
| 5  | `presign_get`       | A signed GET URL is generated (used to serve files to the frontend)                 |
| 6  | `presign_post`      | A signed POST URL is generated (used for direct upload from the applicant's device) |
| 7  | `list_objects`      | Listing objects by prefix                                                           |
| 8  | `category_dispatch` | Correct split by category (documents / selfies / ...)                               |
| 9  | `health_quick`      | Baseline provider health check                                                      |
| 10 | `probe_cleanup`     | All created probe objects are removed                                               |
| 11 | `public_access`     | Anonymous read must be denied (Block Public Access)                                 |
| 12 | `clock_skew`        | Server / client clock skew is within 60 s                                           |
| 13 | `multipart_upload`  | Multipart upload of large files works correctly                                     |
| 14 | `copy_object`       | Server-side copy is supported (required for shared → tenant migration)              |

A 15th probe (`sse_enforcement`) runs when `sse_mode = "sse_kms"` and suggests a bucket policy that denies uploads without encryption.

The response contains per-probe details:

```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
  }
}
```

For each failed check an `action_hint` is returned — a concrete suggestion ("add `s3:PutObject`", "remove Public Access"). Validation returns `200 OK` even when some probes fail (with `all_passed=false`) — this lets the UI show a checklist without parsing the error envelope.

## Activation

After validation succeeds, the configuration is moved to the active state:

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

The `X-Confirm-Tenant` header is required — it guards against accidental activations. Its value must match the current tenant's UUID.

After activation:

* **Every new upload** (documents, selfies, etc.) goes straight into the connected tenant bucket.
* **Historical data** in shared storage is copied in the background.

The action is recorded in the audit log (`tenant.updated` + `action=storage_config_activated`).

## Historical data migration

Activation switches new uploads to the connected bucket immediately. Files that were uploaded earlier remain available while OneKYC copies them to the tenant bucket.

### Parameters

* **Rate limit:** 10 MB/s per tenant by default.
* **Integrity check:** copied objects are verified after upload.
* **Grace period:** 30 days between migration completion and the deletion of copies from shared storage.

### Migration job states

| Status      | Description                                   |
| ----------- | --------------------------------------------- |
| `pending`   | Job created, waiting to start                 |
| `running`   | Copying in progress                           |
| `paused`    | Paused (only via super-admin)                 |
| `completed` | All objects copied successfully               |
| `failed`    | Retry limit reached; see `last_error`         |
| `cancelled` | The tenant initiated Disable during migration |

### Migration status request

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

The response contains `total_objects` / `copied_objects` / `failed_objects` counters, byte progress, `started_at`, `completed_at`, and the last error.

## Health monitoring

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

Returns the latest storage health snapshot:

| Field                            | Value                                    |
| -------------------------------- | ---------------------------------------- |
| `status`                         | `healthy` / `degraded` / `unhealthy`     |
| `last_check_at`                  | Time of the most recent check            |
| `last_success_at`                | Time of the most recent successful check |
| `consecutive_failures`           | Number of consecutive failed checks      |
| `last_error` / `last_error_code` | The latest error                         |
| `alert_sent_at`                  | When a degradation notification was sent |

Health checks run automatically in the background (a lightweight probe). After several consecutive failures, the tenant administrator receives an email notification.

## Credentials rotation

If the keys are compromised or scheduled rotation is due:

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

{
  "access_key_id": "<new AccessKey>",
  "secret_key": "<new SecretKey>"
}
```

For AWS S3, `aws_role_arn` is typically rotated and the ExternalID is regenerated; for GCS — a new `service_account_json`; for Azure — a new Account Key / SAS Token.

After rotation the status resets to `pending` — you have to run **Validate** again, and only after a successful validation does the configuration become active again (`Activate`).

## Disable

If the tenant wants to return to shared storage:

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

{
  "reason": "Reason description (minimum 10 characters)"
}
```

Effect:

* The configuration is moved to `disabled`.
* Every **new** upload goes back into OneKYC's shared storage.
* An unfinished migration is cancelled (status `cancelled`).
* **Files already written to the tenant bucket are NOT deleted** — the tenant retains full control over its bucket and can delete them on its own.
* Those files remain available until the configuration is deleted.

The `reason` field is required (minimum 10 characters) — it is recorded in the audit log for incident analysis.

To connect another bucket later, you have to create a new configuration from scratch.

## Deleting a configuration

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

Only configurations in `pending`, `validated`, or `failed` status can be deleted. An active configuration must first be moved to `disabled` through the `/disable` endpoint.

## Credentials encryption

All secrets (`secret_key`, `service_account_json`, Azure keys) are stored in the database in encrypted form:

* Algorithm — AES-256-GCM (envelope encryption).
* The DEK (Data Encryption Key) is derived from the OneKYC master key through HKDF-SHA256, with the tenant UUID as salt and a version-tagged info string.
* The master key is stored in the `STORAGE_MASTER_KEY` environment variable (it does not overlap with `ENCRYPTION_KEY`).
* When the encryption format changes, old ciphertexts can still be decrypted through `credentials_version`, while new ones are encrypted with the current version.

Secrets cannot be read out through the API — even OneKYC super-admins do not have an interface for extraction. If they are lost, the only recovery path is rotation (`/rotate`) with new credentials.

## FAQ

### How much does BYOB cost?

On the OneKYC side — free (the option is available on every plan). Storage and traffic costs are paid by the tenant directly to its cloud provider (AWS, GCP, Cloudflare, etc.).

### Will upload latency increase?

Marginally. OneKYC uses presigned URLs — the applicant's browser uploads the file directly into the tenant bucket, bypassing the OneKYC API. Latency is determined by the chosen bucket region (pick the region closest to the majority of users).

### What happens if AWS / KMS is unavailable?

Uploads during that period return a 5xx error; OneKYC does not "fall back" to shared automatically (that would break the compliance invariant). For long outages, the tenant administrator can run **Disable** — and writes go back into shared storage.

The health check records degradation and sends an email notification after several consecutive failures.

### Can I have two active configurations at once?

No. A tenant can have only one active configuration at a time. To switch to another bucket, the current active configuration must first be `Disable`d, then a new one created.

## Diagnostics

| Check                        | Typical failure cause                                            | Action                                                                                                             |
| ---------------------------- | ---------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
| `head_bucket`                | Bucket does not exist / no `s3:ListBucket`                       | Verify the bucket name; add the permission                                                                         |
| `put_probe`                  | No `s3:PutObject`                                                | Check the permission policy                                                                                        |
| `get_probe` / `delete_probe` | No `s3:GetObject` / `s3:DeleteObject`                            | Add the permissions                                                                                                |
| `presign_get`                | Endpoint is misconfigured                                        | Verify `endpoint` for S3-compat                                                                                    |
| `public_access`              | The bucket responds to anonymous requests                        | Enable Block Public Access (AWS) / `publicAccessPrevention=enforced` (GCS) / `allowBlobPublicAccess=false` (Azure) |
| `clock_skew`                 | Clock skew exceeds 60 s                                          | Configure NTP on the source server                                                                                 |
| `multipart_upload`           | Provider does not support multipart, or no permissions for parts | Verify multipart support with the provider                                                                         |
| `copy_object`                | No `s3:CopyObject`, or cross-bucket copy is blocked              | Add `s3:GetObject` + `s3:PutObject` on source and target buckets                                                   |

All errors are also recorded in the audit log (`storage_config_validated` / `storage_config_activated` / `storage_config_disabled`).

## Troubleshooting

### Progress shows `0 / 0` with non-zero error count

If you still see `0 / 0` on a fresh activation, the underlying file inventory is empty — there are no historical files in shared storage to migrate, only new uploads will appear. This is the expected state for a new tenant.

### Status **Completed** but the row reports failed objects

If some files fail to copy, the job moves to `failed`. OneKYC retries it automatically, and the panel exposes a **Retry migration** button for manual immediate retry.

If the job stays `failed` after several attempts, inspect the `last_error` text on the panel. The most common causes:

* `signature does not match` — credentials contain whitespace, or `region` does not match the endpoint URL segment. Rotate credentials, paste them WITHOUT trailing newlines.
* `permission denied` — bucket policy denies `s3:PutObject` or the IAM role lacks an inline policy. Re-check the policy from the wizard.
* `bucket not found` — the bucket was deleted or renamed out of band.

### Health code reference

The health panel shows a short error code and a localised hint. The full mapping:

| Code                         | Meaning                             | Action                                                   |
| ---------------------------- | ----------------------------------- | -------------------------------------------------------- |
| `CONFIG_NOT_FOUND`           | Storage config row missing in DB    | Re-run **Activate**                                      |
| `CREDENTIALS_INVALID`        | Driver rejected the credentials     | **Rotate** with fresh keys                               |
| `EXTERNAL_ID_MISMATCH`       | AWS AssumeRole external ID differs  | Re-paste the External ID into the IAM trust policy       |
| `TENANT_STORAGE_UNAVAILABLE` | Provider transiently unreachable    | Wait for next probe; if persistent — check with provider |
| `PERMISSION_DENIED`          | Bucket policy blocks the operation  | Adjust the bucket / IAM policy                           |
| `BUCKET_NOT_FOUND`           | Bucket deleted out of band          | Re-create the bucket OR **Disable** + new config         |
| `SIGNATURE_MISMATCH`         | Region drift, or whitespace in keys | Rotate credentials, fix the region field                 |
| `HEALTH_CHECK_FAILED`        | Generic catch-all                   | See `last_error` for details                             |

### Path-style addressing

S3-compatible providers differ in their URL style. Some buckets use **path-style** URLs (`host/bucket/key`); others use **virtual-hosted** URLs (`bucket.host/key`).

OneKYC stores the selected value and uses it as-is when creating the storage provider. The next **Validate** step probes the bucket with that exact mode; if the provider rejects the addressing style, go back and switch **Use path-style**, then validate again.

See also: [Audit log](/one-kyc/admin/audit.md), [Tenant settings](/one-kyc/admin/settings.md).
