> ## Documentation Index
> Fetch the complete documentation index at: https://docs.tornadoapi.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Alibaba Cloud OSS

> Deliver Tornado downloads into your own OSS bucket: bucket and region, a RAM user scoped to that bucket, AccessKey pair, and the four dashboard fields

## Overview

Tornado writes every completed file into the OSS bucket you configure, then hands
your customers a **presigned GET URL** for it (the `s3_url` / `download_url` field
in job status and in the `job.completed` webhook).

Both halves matter for the RAM policy. The worker needs to **write**, and the
presigned link needs the same identity to be allowed to **read**. A presigned URL
carries no permissions of its own: it is signed with your AccessKey pair, and OSS
evaluates the download as that identity. Grant write without read and the setup
looks healthy right up until a customer clicks a link.

<Warning>
  **"Test connection" does not contact Alibaba at all for OSS.**

  For S3 the dashboard performs a real five-step round trip: it writes a probe
  object, reads it back, opens and aborts a multipart upload, deletes the probe,
  and reports the measured latency. For Azure Blob it writes and deletes. For OSS
  it does none of that. The check is **structural only**. It verifies that
  `bucket`, `region`, `access_key_id` and `access_key_secret` are non-empty
  strings and returns:

  ```json theme={null}
  {
    "ok": true,
    "verified": false,
    "latency_ms": 0,
    "note": "credentials look structurally valid — live round-trip test is not yet supported for provider 'oss'"
  }
  ```

  `verified: false` is the honest part of that response. A green result proves
  the form is filled in. It does **not** prove the credentials are valid, that
  the bucket exists, that the region is right, or that the RAM policy grants
  anything. No packet leaves Tornado.

  See [Verifying for real](#verifying-for-real) before you rely on this
  destination.
</Warning>

<Note>
  The Settings screen shows an **Endpoint (optional)** field for OSS. On the
  dashboard route that value is accepted, stored and echoed back masked, but
  nothing reads it: the structural check ignores it, and the dashboard's own
  presigning path does not support OSS at all. The endpoint only becomes
  load-bearing on the API route, [`POST /user/oss`](/api-reference/user/configure-oss),
  where it is required and where the region is derived from it. Leave it empty in
  Settings unless you are mirroring an API configuration.
</Note>

***

## Before you start

<Info>
  You need:

  * An **Alibaba Cloud account** with permission to create an OSS bucket, a RAM user, and a custom RAM policy.
  * An **OSS bucket** dedicated to Tornado deliveries. Mixing it with unrelated data makes a scoped policy pointless.
  * A **RAM user with a permanent AccessKey pair**. Tornado stores the pair and signs its own requests, so an STS session token is not usable here: there is no field for one.
  * Admin or Owner role in your Tornado organisation. `POST /settings/storage` is gated on that role; ordinary members can read the configuration but not change it.
</Info>

Alibaba redesigns its console periodically. The navigation below names
destinations rather than pixel positions. If a label has moved, the page you want
is still the one that owns that object (bucket, user, policy).

***

## 1. Create the bucket and choose a region

<Steps>
  <Step title="Create the bucket">
    In the **OSS console** (`oss.console.aliyun.com`), open **Buckets** and choose
    **Create Bucket**. You set **Bucket Name** and **Region** in the same panel.
  </Step>

  <Step title="Choose the region deliberately">
    A bucket's region is the physical location of the data and **cannot be changed
    after creation**. Getting it wrong means creating a second bucket and moving
    the objects. Pick the region closest to whoever consumes the files: your
    backend, your users, or your CDN origin.
  </Step>

  <Step title="Keep the bucket private">
    Leave the ACL at **Private**. Tornado never needs public objects. Delivery
    works through presigned URLs, which is exactly what makes a private bucket
    sufficient.
  </Step>
</Steps>

### The region identifier

Two related strings are easy to confuse:

* The **region ID**, for example `cn-hangzhou`.
* The **endpoint host**, which is the region ID with an `oss-` prefix and the
  Alibaba suffix: `oss-cn-hangzhou.aliyuncs.com`.

Tornado's **Region** field wants the middle form, `oss-` plus the region ID, which
is what the placeholder in the wizard shows:

| Region              | Region ID        | Value for the **Region** field | Public endpoint                           |
| ------------------- | ---------------- | ------------------------------ | ----------------------------------------- |
| China (Hangzhou)    | `cn-hangzhou`    | `oss-cn-hangzhou`              | `https://oss-cn-hangzhou.aliyuncs.com`    |
| China (Shanghai)    | `cn-shanghai`    | `oss-cn-shanghai`              | `https://oss-cn-shanghai.aliyuncs.com`    |
| China (Beijing)     | `cn-beijing`     | `oss-cn-beijing`               | `https://oss-cn-beijing.aliyuncs.com`     |
| Singapore           | `ap-southeast-1` | `oss-ap-southeast-1`           | `https://oss-ap-southeast-1.aliyuncs.com` |
| Japan (Tokyo)       | `ap-northeast-1` | `oss-ap-northeast-1`           | `https://oss-ap-northeast-1.aliyuncs.com` |
| Germany (Frankfurt) | `eu-central-1`   | `oss-eu-central-1`             | `https://oss-eu-central-1.aliyuncs.com`   |
| US (Silicon Valley) | `us-west-1`      | `oss-us-west-1`                | `https://oss-us-west-1.aliyuncs.com`      |
| US (Virginia)       | `us-east-1`      | `oss-us-east-1`                | `https://oss-us-east-1.aliyuncs.com`      |

<Tip>
  Do not work from this table alone, and do not guess from a city name. Open the
  bucket in the OSS console and read its **Overview** page: the **Port** section
  lists the bucket's own endpoints, public and internal. Copy the region segment
  from there. Alibaba operates many more regions than the eight above.
</Tip>

***

## 2. Public, internal and accelerate endpoints

Every OSS region publishes several endpoint hosts for the same bucket. They are
not interchangeable:

| Form                  | Example                                 | Reachable from                                                                         |
| --------------------- | --------------------------------------- | -------------------------------------------------------------------------------------- |
| Public                | `oss-cn-hangzhou.aliyuncs.com`          | Anywhere on the internet                                                               |
| Internal (intranet)   | `oss-cn-hangzhou-internal.aliyuncs.com` | Only Alibaba Cloud instances in the **same region**                                    |
| Dual-stack            | `cn-hangzhou.oss.aliyuncs.com`          | Internet, IPv4 and IPv6, not offered in every region                                   |
| Transfer acceleration | `oss-accelerate.aliyuncs.com`           | Anywhere, but only after acceleration is enabled on the bucket, and it is billed extra |

<Warning>
  **Use the public endpoint.** Tornado's workers are not inside your VPC, so an
  internal endpoint is unreachable for them: the connection simply hangs until it
  times out. An internal host is easy to copy by accident, because the console
  lists it directly beneath the public one, and the two differ by the single word
  `-internal`.

  The accelerate hosts carry no region in the name. Tornado derives the signing
  region from the endpoint hostname, so an accelerate host does not yield a usable
  region and is not a supported form on this route. Stay on the regional public
  endpoint.
</Warning>

***

## 3. Create a RAM user scoped to the bucket

Do not hand Tornado your root account AccessKey. Create a RAM user whose only
permission is the delivery bucket.

### The policy: minimum

Exactly the four actions Tornado calls against your bucket, and nothing else.
Replace `my-tornado-downloads` with your bucket name and save it as a custom
policy.

```json Minimum delivery policy theme={null}
{
  "Version": "1",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "oss:PutObject",
        "oss:GetObject",
        "oss:DeleteObject",
        "oss:AbortMultipartUpload"
      ],
      "Resource": [
        "acs:oss:*:*:my-tornado-downloads/*"
      ]
    }
  ]
}
```

Why each action is there, and nothing more:

| Action                     | Needed for                                                                                                                                                                                                                                                                                                                                |
| -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `oss:PutObject`            | Every upload, and the credential probe's write. Alibaba's API reference names `oss:PutObject` as the permission for `InitiateMultipartUpload`, `UploadPart` and `CompleteMultipartUpload` as well as a simple `PutObject`, so the whole multipart sequence needs no extra action                                                          |
| `oss:GetObject`            | The presigned download URLs Tornado hands your customers. Omit it and jobs still complete, files still land, and every delivered link returns `403`                                                                                                                                                                                       |
| `oss:DeleteObject`         | The credential probe's cleanup. Without it the probe leaves `verify_credentials.txt` at the bucket root on every configuration attempt                                                                                                                                                                                                    |
| `oss:AbortMultipartUpload` | Stopping a partial upload. Alibaba gives it its own action name, so `oss:PutObject` does not cover it. Without it the abort is refused and the uploaded parts linger as an incomplete multipart upload: not objects, so absent from the object list, and Alibaba is explicit that "uploaded parts consume storage and incur storage fees" |

<Note>
  **Do not add `oss:InitiateMultipartUpload`, `oss:UploadPart` or
  `oss:CompleteMultipartUpload`.** Above 50 MB uploads are multipart, and with
  Tornado's streaming upload path a file of any size may be, but all three of
  those calls are authorised by `oss:PutObject`. `AbortMultipartUpload` is the
  only step of the lifecycle with a permission of its own, which is exactly why
  it is the one that gets left out.
</Note>

### The policy: recommended

**The minimum is the recommendation.** OSS has nothing worth adding: every other
action Tornado could plausibly need is either unused or already covered.

| Not needed                                                                    | Why                                                                                                                                         |
| ----------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
| `oss:ListObjects`                                                             | Nothing in the delivery path enumerates the bucket. Add it only if a human will browse the bucket in the console signed in as this RAM user |
| `oss:ListMultipartUploads`, `oss:ListParts`                                   | The OSS backend never sweeps for orphaned uploads, unlike the S3 one                                                                        |
| `oss:GetBucketInfo`, `oss:PutObjectAcl`, `oss:PutObjectTagging`               | Tornado never reads bucket configuration, sets an ACL, or tags an object                                                                    |
| A bucket-level resource ARN (`acs:oss:*:*:my-tornado-downloads` without `/*`) | Every action above is object-level. Adding the bare bucket ARN costs nothing and does nothing                                               |

The one thing worth configuring is not a permission: set a **lifecycle rule on
the bucket to expire incomplete multipart uploads**. It catches the cases
Tornado cannot clean up itself, such as a worker that died mid-upload, and it
needs no RAM permission at all.

<Warning>
  **Scope the resource to the whole bucket, not to `videos/`.** Delivered files
  land under `videos/`, but the credential probe writes `verify_credentials.txt`
  at the bucket root. A policy narrowed to `my-tornado-downloads/videos/*` fails
  credential validation with `403 AccessDenied` even though real deliveries would
  have worked.
</Warning>

### Creating the user and attaching the policy

<Steps>
  <Step title="Create the custom policy">
    In the **RAM console**, go to **Permissions** then **Policies**, and choose
    **Create Policy**. Switch to the **JSON** editor, paste the policy above, and
    give it a name such as `TornadoDelivery`.
  </Step>

  <Step title="Create the RAM user">
    Go to **Identities** then **Users**, and choose **Create User**. Give it a
    logon name such as `tornado-delivery`.

    For **Access Mode**, select programmatic access only (the option for using a
    permanent AccessKey). Do not also grant console access. This is a machine
    identity and never needs to sign in.
  </Step>

  <Step title="Attach the policy">
    From the user's row, use **Attach Policy** / **Grant Permission** and select
    the `TornadoDelivery` policy. Attach nothing else.
  </Step>
</Steps>

***

## 4. Get the AccessKey pair

If you created the user with programmatic access, the AccessKey pair is generated
during creation. Otherwise, open the user, go to its **Credential** tab, and in
the **AccessKey** section choose **Create AccessKey**.

You get an **AccessKey ID** (Alibaba's RAM user keys begin `LTAI`) and an
**AccessKey secret**.

<Warning>
  The AccessKey secret is displayed **once**, at creation, and Alibaba cannot show
  it again. Copy both values now. If you lose the secret, delete the AccessKey and
  create a new one.

  A RAM user may hold at most two AccessKey pairs. That limit is the rotation
  mechanism: create the second, update Tornado, confirm deliveries and downloads
  still work, then delete the first.
</Warning>

***

## 5. Fill in the fields

Tornado's OSS configuration is `bucket`, `region`, `access_key_id`,
`access_key_secret`, plus an optional `endpoint`.

<Steps>
  <Step title="Onboarding wizard">
    On the **Destination** step, choose the **Alibaba OSS** tile. The
    **Credentials** step shows four inputs, and the note "Use a RAM user scoped to
    this bucket", which is the policy from step 3.

    | Label on screen      | Field               | Value                                                    |
    | -------------------- | ------------------- | -------------------------------------------------------- |
    | **Bucket name**      | `bucket`            | The bucket name only, no `oss://`, no host, no path      |
    | **Region**           | `region`            | `oss-` plus the region ID, for example `oss-cn-hangzhou` |
    | **AccessKey ID**     | `access_key_id`     | `LTAI…`                                                  |
    | **AccessKey secret** | `access_key_secret` | The secret from step 4                                   |

    The wizard has no endpoint field for this tile.
  </Step>

  <Step title="Or Settings, after onboarding">
    Dashboard **Settings**, section **Default storage destination**, provider
    **Alibaba Cloud OSS**. The same fields appear, labelled **Bucket**,
    **Region**, **Access Key ID** and **Access Key Secret**, plus
    **Endpoint (optional)**.

    Settings has a save action and no "Test connection" button, so nothing is
    checked there at all. That is not much of a loss for OSS, given what the test
    does and does not do.
  </Step>
</Steps>

<Info>
  Credentials submitted through the dashboard are written to Azure Key Vault, not
  to the application database. Only the provider name and a vault reference are
  stored alongside your organisation, and reading the configuration back returns
  the secret masked. If Key Vault is unreachable, the save is refused with a 503
  rather than falling back to plaintext storage.
</Info>

***

## Verifying for real

Since the dashboard test never leaves the building for OSS, verification is on
you. There are two checks, and they answer different questions.

### Check 1: make Alibaba answer

[`POST /user/oss`](/api-reference/user/configure-oss) configures storage for one
API key **and performs a live probe while doing it**: it uploads
`verify_credentials.txt` to the bucket root, then deletes it (best effort). A
`200` means Alibaba accepted a real authenticated write. A `400` means it did not,
and the response carries the reason.

Note the different contract on this route: it takes a full `endpoint` URL and no
`region`.

```bash theme={null}
curl -X POST "https://api.tornadoapi.io/user/oss" \
  -H "x-api-key: sk_your_api_key" \
  -H "Content-Type: application/json" \
  -d '{
    "endpoint": "https://oss-cn-hangzhou.aliyuncs.com",
    "bucket": "my-tornado-downloads",
    "access_key_id": "LTAI...",
    "access_key_secret": "...",
    "folder_prefix": "videos/"
  }'
```

<Warning>
  Storage on this route is configured **per API key, not per account**. A
  configuration set on one key does not apply to your other keys, and a job sent
  with an unconfigured key lands in Tornado's managed storage while still
  reporting `Completed`. This is the single most common cause of "my file isn't in
  my bucket". Use the key you actually send jobs with. See
  [Custom Storage](/features/custom-storage) for the full rule.
</Warning>

### Check 2: prove the read half

The probe in check 1 writes and deletes. It never reads, so it cannot detect a
missing `oss:GetObject`, and it never opens a multipart upload, so it cannot
detect a missing `oss:AbortMultipartUpload` either. The dashboard's S3 probe was
extended to cover both; **the OSS path was not**, so this check is still on you.

Run one real job, take the `s3_url` from the job status or the `job.completed`
webhook, and fetch a single byte:

```bash theme={null}
# 200 or 206 = oss:GetObject is granted, delivery works end to end.
# 403        = the policy is missing oss:GetObject.
curl -s -o /dev/null -w '%{http_code}\n' -r 0-0 "$OSS_URL"
```

Then open the bucket in the OSS console and confirm the object is where you expect
it: under `videos/` by default, with any `folder_prefix` nested inside that. See
[Custom Storage](/features/custom-storage) for the full path layout.

<Note>
  Fixing the policy repairs **existing** links. A presigned URL is evaluated at
  request time against the current policy, so a link issued while `oss:GetObject`
  was missing starts working the moment you grant it, as long as it has not
  expired.
</Note>

***

## Troubleshooting

Because the dashboard test is structural, the only error strings it can produce
are about the form itself:

| Message                                                                                 | Meaning                                                                               |
| --------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- |
| `missing required field(s): bucket` (or `region`, `access_key_id`, `access_key_secret`) | One of the four fields was submitted empty. This is the entire scope of the OSS check |

Everything else surfaces from `POST /user/oss` or from a real job. Those errors are
prefixed `Invalid configuration:` for local validation and
`Credential validation failed: Upload test failed:` for anything Alibaba returned.

| Symptom                                                                                                      | Cause                                                                                                                                                                                | Fix                                                                                                                                                                     |
| ------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `403 AccessDenied`, "The bucket you are attempting to access must be addressed using the specified endpoint" | **Wrong region identifier.** The bucket lives in one region and you addressed another. Every OSS bucket is pinned to its region's endpoint                                           | Read the region from the bucket's **Overview** page in the console, not from a guess about its name                                                                     |
| Request hangs, then `Credential validation timed out after 30s`, or a job stalls in upload                   | **Internal endpoint.** `oss-<region>-internal.aliyuncs.com` only resolves usefully from inside Alibaba Cloud in that region. Tornado is outside it                                   | Use the public host, without `-internal`                                                                                                                                |
| `403 AccessDenied` on upload                                                                                 | RAM policy missing `oss:PutObject`, or the `Resource` list is missing the `my-bucket/*` line. Object actions need the `/*` form                                                      | Compare against [the minimum policy](#the-policy-minimum). Check for an explicit `Deny` in another attached policy or in the bucket policy, which overrides any `Allow` |
| Jobs complete, files are in the bucket, every download link returns `403`                                    | RAM policy missing `oss:GetObject`. Neither the dashboard check nor the API route's write probe can catch this. The S3 probe was extended to read; **the OSS path was not**          | Add `oss:GetObject` on the `/*` resource, then re-check with a real link, not with the wizard                                                                           |
| `verify_credentials.txt` sitting at the bucket root after every configuration attempt                        | RAM policy missing `oss:DeleteObject`. The probe's cleanup is best effort, so the configuration still succeeds and the file simply stays                                             | Add `oss:DeleteObject` on the `/*` resource, then delete the leftovers by hand. They are safe to remove                                                                 |
| `403 AccessDenied`, "The bucket you access does not belong to you"                                           | **Bucket in a different account.** The AccessKey belongs to account A and the bucket to account B. RAM policies are account-scoped, so no policy in A can grant access to B's bucket | Create the bucket in the same account as the RAM user, or use a bucket policy on the owning account to grant the external RAM user explicitly                           |
| `404 NoSuchBucket`                                                                                           | The bucket does not exist in that region under that account                                                                                                                          | Typo in **Bucket name**, or a path was entered (`my-bucket/videos`) instead of a bare bucket name. Put the path in `folder_prefix` instead                              |
| `403 InvalidAccessKeyId`                                                                                     | Alibaba does not recognise the AccessKey ID                                                                                                                                          | The key was deleted or disabled, or the RAM user was deleted. Create a new AccessKey                                                                                    |
| `403 SignatureDoesNotMatch`                                                                                  | The ID is real, the secret does not match it                                                                                                                                         | Almost always a truncated paste or trailing whitespace in **AccessKey secret**. Re-paste. If you no longer have it, create a new AccessKey                              |
| `Invalid configuration: OSS endpoint must start with http:// or https://`                                    | The endpoint was given as a bare host                                                                                                                                                | Prefix it with `https://`                                                                                                                                               |
| `Invalid configuration: Access key is too short` / `Secret key is too short`                                 | A field holds fewer than five characters                                                                                                                                             | A partial paste. Re-enter both                                                                                                                                          |
| Incomplete multipart uploads accumulating, storage bill creeping up                                          | RAM policy missing `oss:AbortMultipartUpload`, so failed uploads cannot clean up their parts                                                                                         | Add the action, and add a lifecycle rule on the bucket to expire incomplete multipart uploads as a backstop                                                             |

***

## Edge cases

<AccordionGroup>
  <Accordion title="Large files and multipart uploads">
    Uploads above roughly 50 MB are sent as multipart, which is most videos.
    Tornado also streams straight from the muxer into OSS for these, so parts are
    uploaded concurrently while the file is still being produced.

    None of that needs extra permissions on the happy path: Alibaba documents
    `oss:PutObject` as the action required to initiate a multipart upload, upload
    parts, and complete it. Only the abort path is separate, which is why
    `oss:AbortMultipartUpload` is in the policy.
  </Accordion>

  <Accordion title="Where files land inside the bucket">
    By default objects go under a `videos/` top-level folder, with any
    `folder_prefix` nested inside it and the per-job folder below that. Both
    `base_folder` and `folder_prefix` are configurable, though only on the API
    route: the dashboard OSS form does not expose either. See
    [Custom Storage](/features/custom-storage) for the full path layout.
  </Accordion>

  <Accordion title="Rotating the AccessKey pair">
    A RAM user can hold two AccessKey pairs at once, which is the whole rotation
    mechanism.

    <Steps>
      <Step title="Create the second pair">
        On the same RAM user, create a second AccessKey.
      </Step>

      <Step title="Update Tornado">
        Save the new pair. In-flight jobs finish with whichever credentials they
        already resolved.
      </Step>

      <Step title="Watch, then delete">
        Confirm new jobs deliver and new links download, then disable the old
        AccessKey. Disabling first is the reversible version of deleting.
      </Step>
    </Steps>

    Presigned URLs signed with the old pair stop working the moment that pair is
    disabled or deleted, even if the link has not expired. If your customers hold
    live links, disable rather than delete, and wait the window out.
  </Accordion>

  <Accordion title="Removing the configuration">
    Deleting the dashboard configuration removes the destination for your
    organisation and the backing Key Vault secret. For a per-key configuration set
    through the API, use
    [`DELETE /user/oss`](/api-reference/user/delete-oss) with that key. After
    removal, new downloads go to Tornado's managed storage. Files already in your
    bucket are untouched.
  </Accordion>
</AccordionGroup>

***

## Related

<CardGroup cols={2}>
  <Card title="Custom Storage" icon="cloud" href="/features/custom-storage">
    All providers, the per-API-key configuration route, folder layout, and inline per-request credentials
  </Card>

  <Card title="Webhooks" icon="bell" href="/features/webhooks">
    The `job.completed` payload, including the presigned URL this policy has to allow
  </Card>
</CardGroup>
