Tornado can deliver completed downloads straight into a Google Cloud Storage bucket you own. Authentication is a service account key: you create the service account in your own project, grant it a role on the bucket, download its JSON key, and paste that key into the dashboard.The dashboard asks for exactly three values: the bucket, the project ID, and the whole service account key file as one value.This page covers the dashboard path (the onboarding wizard and Settings › Default storage destination), which writes an organization-level configuration through POST /settings/storage. Credentials are stored in Azure Key Vault and are returned masked when the configuration is read back.
The dashboard route is not the same contract as the per-API-key endpoint
POST /user/gcs. That
endpoint names the credentials field service_account_json and additionally
accepts folder_prefix and base_folder. The dashboard route names the field
credentials_json and accepts three fields only. Do not copy a payload
from one to the other.
Read these before you create anything in Google Cloud. Both are properties of the GCS route as it exists today, not Google restrictions.
“Test connection” does not contact Google. For GCS there is no probe
object, no API call, and no credential check of any kind. The test loads the
submitted configuration, confirms that bucket, project_id and
credentials_json are present and non-empty, and returns:
{ "ok": true, "verified": false, "latency_ms": 0, "note": "credentials look structurally valid — live round-trip test is not yet supported for provider 'gcs'"}
The wizard renders that as a green banner reading “Credentials accepted
and saved, verified on your first delivery.” While the request is in flight
the button reads “Writing test object…”, which is the shared wizard label:
for GCS nothing is written anywhere.A green result here proves that you filled in three boxes. It does not parse
the JSON, and it says nothing about the key, the role, the project or the
bucket. Every one of those failures surfaces later, on a real job. Verify with
a real delivery before you consider the setup done.The S3 branch of the same endpoint was recently extended to write, read back,
open and abort a multipart upload, and delete. None of that applies here.
If you have read the AWS S3 or
Cloudflare R2 page, discard what it says about the
probe: for GCS no packet leaves Tornado.
The bucket must already exist. Tornado never creates it, and because the
connection test makes no call, a bucket that does not exist is accepted at
configuration time and fails on the first job.
You need a Google Cloud project and, within it, permission to create a bucket, grant IAM on that bucket, and create a service account key.
Service account key creation may be blocked by organization policy. The
constraints/iam.disableServiceAccountKeyCreation constraint prevents key
creation in a project, and may be enforced by your organization. If key creation fails with a policy error, an organization
administrator must grant an exception for the project before you can continue.
Tornado authenticates with a service account key only: Workload Identity
Federation, attached service accounts and impersonation are not supported on
this route.
In the Google Cloud console,
go to Cloud Storage › Buckets, then select Create. Console layouts
change; if the entry is not where you expect it, search for “Cloud Storage”
from the top search bar.
2
Name it
Bucket names are globally unique across all of Google Cloud. Tornado
additionally validates the name before every upload: 3 to 63 characters,
lowercase letters, digits, hyphens and periods only, starting and ending
with a letter or digit, no consecutive periods, and not shaped like an IP
address. A name Google accepts but Tornado rejects will fail at job time,
not at save time.
Tornado authorises entirely through IAM and never reads or writes object
ACLs, so uniform bucket-level access is the right setting and is what the
rest of this page assumes. Note that uniform bucket-level access cannot be
turned off again once it has been active on a bucket for 90 consecutive
days.
Google offers regions, dual-regions and multi-regions. Choose according to your availability requirements, data-location constraints and the provider’s current storage, replication and transfer prices. Re-running a download is only an option while the original media remains available.Compare upload duration and recipient download latency using real deliveries to candidate locations. The GCS dashboard test returns latency_ms: 0 without contacting Google, so it cannot measure distance to Tornado’s workers.
Go to IAM & Admin › Service Accounts in the
console, select
your project, then select Create service account.
2
Name it for this purpose only
Something like tornado-delivery. Use one service account per delivery
destination so you can revoke it without affecting anything else.
3
Skip the project-level role
The creation flow offers an optional step titled along the lines of “Grant
this service account access to project”. Skip it. Granting a role there
applies it to every bucket in the project, present and future. You will
grant the role on one bucket in the next step.
4
Copy the service account email
It looks like
tornado-delivery@my-gcp-project.iam.gserviceaccount.com. You need it for
the IAM grant.
gcloud iam service-accounts create tornado-delivery \ --project=my-gcp-project \ --display-name="Tornado delivery"
The role has to cover every operation the delivery path performs, not just the upload. The default file-upload strategy depends on size and concurrency. Large files can use parallel composite upload, while an optional worker mode uses XML multipart. Streaming uploads have their own strategy; a small dashboard probe does not exercise these paths.
Operation
When it happens
IAM permission
Single-request upload
Files under 5 MiB in the default file path
storage.objects.create
Resumable upload
Files of 5 MiB or more when composite/XML is not selected
storage.objects.create
Parallel composite upload: part uploads
Files of at least 100 MiB when composite upload is enabled and concurrency exceeds one
storage.objects.create
Parallel composite upload: objects.compose
Same, once the parts are uploaded
storage.objects.createandstorage.objects.get
Deleting the temporary parts after a compose
After every composite upload
storage.objects.delete
Overwriting an object that already exists
Re-running a job that writes the same key
storage.objects.createandstorage.objects.delete
Deleting a delivered object
Cleanup operations
storage.objects.delete
Credential probe on POST /user/gcs
Only on the API route, at configuration time
storage.objects.create, then storage.objects.delete to clean up after itself
Serving a signed download URL
When someone opens a delivered link
storage.objects.get
Two of these deserve spelling out.Composite uploads read what they wrote. When selected, the composite path uploads parts and merges them server-side with objects.compose, in batches of up to 32 sources. Google’s reference for that method lists storage.objects.create and storage.objects.get as always required, and storage.objects.delete as required “if the new composite object overwrites an existing object”. Tornado then deletes the temporary parts, which are named after the destination key with a .tornado.tmp.<uuid>.<nnnnn> suffix.
Two bucket settings interact badly with those temporary parts. On a bucket with object versioning enabled, each temp part leaves a noncurrent version behind when it is deleted, billed until a lifecycle rule reaps it. On a bucket with a retention policy or bucket lock, the temp-part deletes fail outright, and no role fixes that: the bucket configuration is the blocker, not the credential.
Signed URLs are signed locally, but read remotely. Tornado builds V4 signed GET URLs offline using the private key in your JSON file, so signing itself does not call Google or validate object-read permissions. The permission check happens when the recipient opens the link, and the request is authorised as the signing service account. Without storage.objects.get, every signed link returns 403 even though the file is sitting in the bucket.
For the default JSON/composite paths, allow these object operations:
storage.objects.create # every upload: media, resumable, composite parts, the compose destination, the probe objectstorage.objects.get # signed download URLs, and objects.compose reading its own source partsstorage.objects.delete # temp-part cleanup, probe cleanup, and overwrite-on-retry
A custom role granted on this bucket avoids giving the service account access to other buckets in the project. Inherited project permissions can also authorize access, but are broader.The default JSON/composite path does not enumerate the bucket. Its operation set does not require storage.objects.list, storage.objects.update, storage.buckets.get or storage.buckets.list.
The optional worker mode GCS_XML_MULTIPART=1 uses XML multipart with the same service-account credential. That mode additionally needs the multipart permissions for creating and aborting uploads. Confirm the deployed upload mode and its IAM requirements before adopting a three-permission custom role. The dashboard does not expose an XML toggle or accept HMAC keys.
A predefined role can be easier to maintain than a custom role; inspect its current permission list before granting it:
Role
Why it is worth the extra
roles/storage.objectUser
Covers object creation, reading and deletion, with additional permissions beyond this default path. Scope the grant to the destination bucket and verify its multipart permissions if the XML mode is enabled
roles/storage.objectAdmin also works and is the answer you will find in most older write-ups; it is a slightly wider version of the same idea. roles/storage.objectViewer and roles/storage.objectCreator together are still not enough, because neither carries delete.Prefer granting the selected role on the destination bucket rather than the whole project.
roles/storage.objectCreator is not enough. It does not provide the
object-read and deletion permissions this delivery flow needs. The current
onboarding hint recommends roles/storage.objectAdmin; a narrower role must
still cover every operation used by your upload mode.With objectCreator alone, small files written to object names that do not
yet exist will appear to work, and then:
A composite upload fails when objects.compose needs
storage.objects.get. The default file path selects composite upload from 100 MiB when enabled and parallelism is available.
Every signed download URL returns 403, because the reader’s GET requires
storage.objects.get.
Re-delivering to an object name that already exists fails, because
overwriting requires storage.objects.delete.
Temporary composite parts and the verify_credentials.txt probe object are
left behind in the bucket, because the cleanup deletes are rejected and
cleanup failures are not surfaced.
Grant a bucket-scoped role covering the operations of your deployed upload
mode, and validate both a real delivery and a recipient download.
In Cloud Storage › Buckets, select the bucket name, then select the
Permissions tab.
2
Grant access
Select Grant access, paste the service account email into New
principals, and choose Storage Object User from Select a role, or
the custom role you created.
3
Save
Select Save. The grant applies to this bucket only.
Check the grant landed on the bucket and not the project:
gcloud storage buckets get-iam-policy gs://tornado-downloads should list your
service account, and gcloud projects get-iam-policy my-gcp-project should
not.
In IAM & Admin › Service Accounts, select the service account email, then
select the Keys tab.
2
Create a JSON key
Select Add key › Create new key, choose JSON, and select Create.
The file downloads immediately.
3
Store it somewhere sane
The private key cannot be downloaded again. Treat the file as a credential:
keep it out of version control, and delete your local copy once it is in the
dashboard.
gcloud iam service-accounts keys create tornado-delivery.json \ --iam-account=tornado-delivery@my-gcp-project.iam.gserviceaccount.com
The file is a JSON object beginning {"type": "service_account", ...}, roughly 2.3 KB. Tornado requires type to be exactly service_account and requires project_id, private_key and client_email to be present.
Open the dashboard, choose Google Cloud Storage as the destination, and fill in the fields. Labels differ between the onboarding wizard and the settings page; both write the same three API fields.
Onboarding label
Settings label
API field
What to paste
Bucket name
Bucket
bucket
The bucket name only, for example tornado-downloads. No gs:// prefix, no URL, no path segments.
Project ID
Project ID
project_id
The project ID, for example my-gcp-project. Not the project name and not the project number.
Service account key (JSON)
Credentials JSON
credentials_json
The entire contents of the downloaded .json file, as a single value, opening brace to closing brace.
Open the downloaded file in a text editor, select all, and paste. The field is a textarea and takes the raw file content, newlines and all. Do not reformat it.
Do not base64-encode the key for this field, and do not escape it by hand.
Tornado parses the stored value directly as JSON. A base64 blob fails with
“Invalid JSON in service account credentials”, and so does a value where the
private_key newlines have been mangled by a copy that went through an
intermediate tool. If you are constructing the API payload programmatically
instead of pasting, let your JSON serialiser escape the string; do not do it
yourself.
Setting the storage destination is an organization-level action restricted to
Admin and Owner members, and one provider is active at a time. Saving a
GCS configuration replaces whatever provider was configured before.
gcloud storage ls -r gs://tornado-downloads/videos/
Delivered files land under the base folder, which defaults to videos/.
3
Then test a large file
A small file may use only the simple-upload path. Run a second job
that produces at least 100 MiB (a longer video at a higher resolution) and
verify the large-file mode used by your deployment. Successful simple upload
does not prove composite or XML multipart permissions.
4
Then test a read
Fetch the delivered object with the service account’s own credentials, which
is the same authorisation a signed download URL uses:
A 403 here can indicate missing storage.objects.get or another access-policy restriction. Also open the actual delivery link from the recipient’s network; a successful command-line read alone does not prove recipient access.
Verify both the object and its delivered URL. The worker uses the actual
GCS backend to create the signed URL, stores it with the completed job, and
includes it as s3_url in the per-job completion webhook. Job polling can
return that recorded URL while it is fresh. The legacy field name does not
imply an S3 destination. A missing or expired link is not proof that upload
failed: also check the object in your bucket.Organization-level custom webhooks sent by the dashboard are a separate path:
that dispatcher currently presigns S3/Azure only, so its download_url is not
available for GCS.
If nothing arrives at all, check which configuration the job used before you
suspect IAM. The job pipeline resolves storage per API key, from the
configuration set through POST /user/gcs
with that key’s x-api-key, falling back to the organization default and then
to Tornado’s managed storage. A job that ran against managed storage still
reports Completed, with the file in Tornado’s bucket rather than yours. See
Custom Storage.
The dashboard’s connection test cannot produce any of these errors, because it makes no request. They surface on the first job instead.The worker treats 401, 403, 404, invalid_grant and Invalid JWT as permanent and fails the job immediately without retrying. When Google’s response identifies the cause, the job’s error says exactly what to fix, and failure_kind classifies it (the job status is failed):
failure_kind
Cause
What the error message gives you
destination_permission
The service account lacks a permission on the bucket
The service account, the missing permission (for example storage.objects.create, or storage.objects.delete when an object name is overwritten) and the narrowest predefined role that grants it
destination_not_found
The bucket does not exist, or the service account cannot see it
The bucket name and service account to check
destination_credentials
Google rejected the key: deleted, rotated, disabled or malformed
Google’s reason, for example Invalid JWT Signature.
destination_billing
The project that owns the bucket cannot accept uploads, for example billing disabled
Google’s message
Other permanent refusals keep the generic message “Invalid GCS storage credentials. Please check your service_account_json, project_id, and bucket name.”, which covers several distinct causes.
Symptom
Cause
Fix
Simple uploads deliver, composite uploads fail
One possible cause is missing storage.objects.get for objects.compose. XML multipart has different operation requirements.
Inspect the failing operation and grant the permissions for the upload mode in use.
Files deliver, but download links return 403
The role lacks storage.objects.get. Signing succeeds offline; the reader’s GET is what gets rejected.
Grant roles/storage.objectUser on the bucket.
Re-running a job that writes an object name that already exists fails
Overwriting an existing object requires storage.objects.delete in addition to storage.objects.create.
Grant roles/storage.objectUser on the bucket.
verify_credentials.txt or *.tornado.tmp.* objects accumulate in the bucket
Cleanup can fail because of missing delete permission, retention rules or an interrupted upload.
Check the failure and retention policy. Remove only confirmed abandoned Tornado objects after their uploads have stopped; a matching name alone does not prove an object is unused.
Every job fails immediately, nothing appears in the bucket
The service account may lack effective access to this bucket, or the key may be invalid. A project grant can be inherited by its buckets; a grant on an unrelated project does not authorize this one.
Check the service-account identity, key and effective bucket IAM, including inherited policies.
Every job fails immediately, and the bucket exists
The bucket name or service-account permissions may be wrong. The service account and bucket can belong to different projects when IAM grants access.
Confirm the bucket name and the service account’s effective permissions; do not require matching project ownership as a substitute for IAM.
Configuration saved green, every job fails on credentials
The pasted JSON is not a complete, valid key file: a partial paste, a base64 blob, or private_key newlines mangled in transit. The dashboard test does not parse the JSON, so this is accepted at save time.
Re-copy the whole file, opening brace to closing brace, into the field.
Jobs that used to work start failing on credentials
The key was disabled or deleted in the console, or the service account itself was deleted or disabled.
Create a new key and update the configuration. Note that deleting a key does not revoke short-lived tokens already issued from it, so a revoked key can appear to keep working briefly.
Service account JSON 'type' must be 'service_account'
An OAuth client secret, an external account configuration, or an authorized_user credential was pasted instead of a service account key.
Download the key from the service account’s Keys tab, not from APIs & Services › Credentials.
Key creation is refused in the console
constraints/iam.disableServiceAccountKeyCreation is enforced on the project, or another organization policy blocks key creation.
Ask an organization administrator to grant an exception for the project.
A service account can hold more than one key at a time, so overlap them and you rotate without an outage.
1
Create a second key
Add a new JSON key on the same service account. Both keys are now valid.
2
Update Tornado and confirm
Paste the new key into the dashboard and run a test job through to the
bucket.
3
Disable, then delete, the old key
Disable the old key first and watch for failures before deleting it.
Deletion is permanent.
Rotation also invalidates links you have already handed out. Google’s
documentation is explicit that a signed URL grants access “until the
expiration time for the URL is reached or the key used to sign the URL is
rotated”, so retiring a key cuts short every outstanding link signed with it,
ahead of its 24 hour expiry. Rotate when those links are expendable, or expect
recipients to need fresh ones.
Not implemented. POST /settings/storage/test returns verified: false for GCS and makes no network call. The per-API-key endpoint POST /user/gcs does validate for real, by uploading and deleting a verify_credentials.txt probe object.
Reading the configuration back
Only through GET /settings/storage, with credentials_json masked. There is no GET /user/gcs.
folder_prefix / base_folder
Not accepted. Available on POST /user/gcs only. Deliveries through the dashboard route use the default videos/ base folder.
Base64-encoded credentials
Not accepted. The value is parsed directly as JSON.
Workload Identity Federation, attached service accounts, impersonation
Not supported. A downloaded service account key is the only credential type.
HMAC keys / selecting XML from the dashboard
No HMAC key fields or customer-facing XML toggle. Workers can optionally use XML multipart with the same service-account credential; the default file path uses the JSON API.
Customer-supplied encryption keys (CSEK)
Not supported. Customer-managed keys (CMEK) configured as the bucket default also require the appropriate Google-side encryption-key permissions; verify with a real delivery.
Automatic bucket creation
Not supported. Create the bucket first.
Was this page helpful?
{"docsConfig":{"$schema":"https://mintlify.com/docs.json","theme":"mint","name":"Tornado API","colors":{"primary":"#2563EB","light":"#3B82F6","dark":"#2563EB"},"favicon":"https://mintcdn.com/velyssoftware/zZsHDTmfozVOSRdG/logo/brand/icon-32.png?fit=max\u0026auto=format\u0026n=zZsHDTmfozVOSRdG\u0026q=85\u0026s=f604c9e8108ee9b88619782247caf1be","logo":{"light":"https://mintcdn.com/velyssoftware/zZsHDTmfozVOSRdG/logo/brand/wordmark-light.svg?fit=max\u0026auto=format\u0026n=zZsHDTmfozVOSRdG\u0026q=85\u0026s=f8154de239f2720639bb0b30f38af458","dark":"https://mintcdn.com/velyssoftware/zZsHDTmfozVOSRdG/logo/brand/wordmark-dark.svg?fit=max\u0026auto=format\u0026n=zZsHDTmfozVOSRdG\u0026q=85\u0026s=720f365cac6a3aff0aa9bea523c435f7"},"api":{"mdx":{"server":"https://api.tornadoapi.io"}},"navbar":{"links":[{"label":"Python SDK","href":"https://github.com/Velys-Software/tornado-python-sdk"},{"label":"Support","href":"mailto:support@velys.software"}],"primary":{"type":"button","label":"Dashboard","href":"https://dash.tornadoapi.io/dashboard"}},"integrations":{"posthog":{"apiKey":"phc_sDfUyHRY3fqFP8KD5GWikuzGptFpkHeVHhJXVjYK9PjE","apiHost":"https://api-dash.tornadoapi.io/api/posthog"}},"styling":{"latex":true},"navigation":{"pages":[]}},"deployment":{"deploymentMetadataValue":{"subdomain":"velyssoftware","hasAudioNarration":false,"actualSubdomain":"velyssoftware","customDomains":["docs.tornadoapi.io"],"gitSource":{"type":"github","owner":"velys-software","repo":"tornadoapi","deployBranch":"master","contentDirectory":"docs","isPrivate":true,"mountPath":"","label":""},"chroma":{"liveDeploymentHistoryId":"6ae20754b8d58072c0216d1e","collectionId":"3fffcd1c-aaa0-41bd-a00c-e014326a0346","schemaVersion":"v3","latestBuiltDeploymentHistoryId":"6ae20754b8d58072c0216d1e"},"feedback":{"thumbs":true},"entitlements":{},"buildId":"6ae20754b8d58072c0216d1e","clientVersion":"0.0.4006","searchFilterCounts":[{"language":"default","tag":"API Reference","count":33},{"language":"default","tag":"User","count":16},{"language":"default","tag":"Jobs","count":6},{"language":"default","tag":"Batch Operations","count":3},{"language":"default","tag":"Metadata","count":3},{"language":"default","tag":"Bulk","count":1}]},"assistantConfig":{"enableAskAiButton":false,"enableStarterQuestions":false,"enableAskAiSearchBar":true},"basePath":"","hasChatPermissions":false,"hasWebMcpPermissions":false,"toggles":[{"name":"kill-isr","enabled":true,"variant":{"name":"disabled","enabled":false,"feature_enabled":true},"impressionData":false},{"name":"agent-mintlify-slack","enabled":true,"variant":{"name":"disabled","enabled":false,"feature_enabled":true},"impressionData":false},{"name":"editor-v7-file-tree","enabled":true,"variant":{"name":"disabled","enabled":false,"feature_enabled":true},"impressionData":false},{"name":"agent-integrations-settings","enabled":true,"variant":{"name":"disabled","enabled":false,"feature_enabled":true},"impressionData":false},{"name":"automations-pr-grouping","enabled":true,"variant":{"name":"disabled","enabled":false,"feature_enabled":true},"impressionData":false},{"name":"custom-prefix","enabled":true,"variant":{"name":"disabled","enabled":false,"feature_enabled":true},"impressionData":false},{"name":"analytics-v7","enabled":true,"variant":{"name":"disabled","enabled":false,"feature_enabled":true},"impressionData":false},{"name":"user-flows-analytics","enabled":true,"variant":{"name":"disabled","enabled":false,"feature_enabled":true},"impressionData":false},{"name":"no-publish-flow","enabled":true,"variant":{"name":"disabled","enabled":false,"feature_enabled":true},"impressionData":false},{"name":"workos-onboarding","enabled":true,"variant":{"name":"disabled","enabled":false,"feature_enabled":true},"impressionData":false},{"name":"llms-txt-v2","enabled":true,"variant":{"name":"disabled","enabled":false,"feature_enabled":true},"impressionData":false},{"name":"pause-universal-search-reconcile","enabled":true,"variant":{"name":"disabled","enabled":false,"feature_enabled":true},"impressionData":false},{"name":"snapshot-serving-live","enabled":true,"variant":{"name":"disabled","enabled":false,"feature_enabled":true},"impressionData":false},{"name":"stripTemperatureMiddleware","enabled":true,"variant":{"name":"disabled","enabled":false,"feature_enabled":true},"impressionData":false},{"name":"per-site-search-delta","enabled":true,"variant":{"name":"disabled","enabled":false,"feature_enabled":true},"impressionData":false},{"name":"support-platform-pylon-zendesk","enabled":true,"variant":{"name":"disabled","enabled":false,"feature_enabled":true},"impressionData":false},{"name":"snapshot-serving","enabled":true,"variant":{"name":"disabled","enabled":false,"feature_enabled":true},"impressionData":false},{"name":"instant-rollback","enabled":true,"variant":{"name":"disabled","enabled":false,"feature_enabled":true},"impressionData":false},{"name":"granular-notifications-server","enabled":true,"variant":{"name":"disabled","enabled":false,"feature_enabled":true},"impressionData":false},{"name":"granular-notifications","enabled":true,"variant":{"name":"disabled","enabled":false,"feature_enabled":true},"impressionData":false},{"name":"editor-private-page-search","enabled":true,"variant":{"name":"disabled","enabled":false,"feature_enabled":true},"impressionData":false},{"name":"automations-fable","enabled":true,"variant":{"name":"disabled","enabled":false,"feature_enabled":true},"impressionData":false},{"name":"fixed-ai-action-pricing","enabled":true,"variant":{"name":"disabled","enabled":false,"feature_enabled":true},"impressionData":false},{"name":"automation-resolve-merge-conflict","enabled":true,"variant":{"name":"disabled","enabled":false,"feature_enabled":true},"impressionData":false},{"name":"scoped-publishing","enabled":true,"variant":{"name":"disabled","enabled":false,"feature_enabled":true},"impressionData":false},{"name":"mintql","enabled":true,"variant":{"name":"disabled","enabled":false,"feature_enabled":true},"impressionData":false},{"name":"page-level-analytics","enabled":true,"variant":{"name":"disabled","enabled":false,"feature_enabled":true},"impressionData":false},{"name":"editor-version-history-v2","enabled":true,"variant":{"name":"disabled","enabled":false,"feature_enabled":true},"impressionData":false},{"name":"editor-nav-generations","enabled":true,"variant":{"name":"disabled","enabled":false,"feature_enabled":true},"impressionData":false},{"name":"billing-page-v2","enabled":true,"variant":{"name":"disabled","enabled":false,"feature_enabled":true},"impressionData":false},{"name":"workspace-projects","enabled":true,"variant":{"name":"disabled","enabled":false,"feature_enabled":true},"impressionData":false},{"name":"assistant-v5","enabled":true,"variant":{"name":"disabled","enabled":false,"feature_enabled":true},"impressionData":false},{"name":"byok-sep-25","enabled":true,"variant":{"name":"disabled","enabled":false,"feature_enabled":true},"impressionData":false},{"name":"fork-pr-previews","enabled":true,"variant":{"name":"disabled","enabled":false,"feature_enabled":true},"impressionData":false},{"name":"revalidation-jwt","enabled":true,"variant":{"name":"disabled","enabled":false,"feature_enabled":true},"impressionData":false},{"name":"vite-origin","enabled":true,"variant":{"name":"disabled","enabled":false,"feature_enabled":true},"impressionData":false},{"name":"editor-agent-model-picker","enabled":true,"variant":{"name":"disabled","enabled":false,"feature_enabled":true},"impressionData":false},{"name":"billing-updated-9-15","enabled":true,"variant":{"name":"disabled","enabled":false,"feature_enabled":true},"impressionData":false},{"name":"workos-sso-scim","enabled":true,"variant":{"name":"disabled","enabled":false,"feature_enabled":true},"impressionData":false},{"name":"hobby-abuse-scan","enabled":true,"variant":{"name":"disabled","enabled":false,"feature_enabled":true},"impressionData":false},{"name":"dashboard-inbox","enabled":true,"variant":{"name":"disabled","enabled":false,"feature_enabled":true},"impressionData":false},{"name":"workos-signup","enabled":true,"variant":{"name":"disabled","enabled":false,"feature_enabled":true},"impressionData":false},{"name":"workos-enabled","enabled":true,"variant":{"name":"disabled","enabled":false,"feature_enabled":true},"impressionData":false}],"googleFontsInlined":false},"customCss":[],"page":{"pagePath":"storage/google-cloud-storage","slug":"/storage/google-cloud-storage","pageMetadata":{"title":"Google Cloud Storage","description":"Configure a GCS bucket as your Tornado delivery destination: bucket, service account, bucket-scoped IAM role, and how to verify it for real","href":"/storage/google-cloud-storage"},"description":{"compiledSource":"\"use strict\";\nconst {jsx: _jsx} = arguments[0];\nconst {useMDXComponents: _provideComponents} = arguments[0];\nfunction _createMdxContent(props) {\n const _components = {\n p: \"p\",\n ..._provideComponents(),\n ...props.components\n };\n return _jsx(_components.p, {\n children: \"Configure a GCS bucket as your Tornado delivery destination: bucket, service account, bucket-scoped IAM role, and how to verify it for real\"\n });\n}\nfunction MDXContent(props = {}) {\n const {wrapper: MDXLayout} = {\n ..._provideComponents(),\n ...props.components\n };\n return MDXLayout ? _jsx(MDXLayout, {\n ...props,\n children: _jsx(_createMdxContent, {\n ...props\n })\n }) : _createMdxContent(props);\n}\nreturn {\n default: MDXContent\n};\n","frontmatter":{},"scope":{}},"pageType":"default","mdx":{"mdxSource":{"compiledSource":"\"use strict\";const{Fragment:_Fragment,jsx:_jsx,jsxs:_jsxs}=arguments[0];const{useMDXComponents:_provideComponents}=arguments[0];function _createMdxContent(props){const _components={a:`a`,code:`code`,em:`em`,li:`li`,p:`p`,pre:`pre`,span:`span`,strong:`strong`,tbody:`tbody`,td:`td`,th:`th`,thead:`thead`,tr:`tr`,ul:`ul`,..._provideComponents(),...props.components},{CodeBlock,Heading,Note,Step,Steps,Table,Tip,Warning}=_components;if(!CodeBlock)_missingMdxReference(`CodeBlock`,true);if(!Heading)_missingMdxReference(`Heading`,true);if(!Note)_missingMdxReference(`Note`,true);if(!Step)_missingMdxReference(`Step`,true);if(!Steps)_missingMdxReference(`Steps`,true);if(!Table)_missingMdxReference(`Table`,true);if(!Tip)_missingMdxReference(`Tip`,true);if(!Warning)_missingMdxReference(`Warning`,true);return _jsxs(_Fragment,{children:[_jsx(Heading,{level:`2`,id:`overview`,children:`Overview`}),`\n`,_jsx(_components.p,{children:`Tornado can deliver completed downloads straight into a Google Cloud Storage bucket you own. Authentication is a service account key: you create the service account in your own project, grant it a role on the bucket, download its JSON key, and paste that key into the dashboard.`}),`\n`,_jsx(_components.p,{children:`The dashboard asks for exactly three values: the bucket, the project ID, and the whole service account key file as one value.`}),`\n`,_jsxs(_components.p,{children:[`This page covers the `,_jsx(_components.strong,{children:`dashboard`}),` path (the onboarding wizard and `,_jsx(_components.strong,{children:`Settings › Default storage destination`}),`), which writes an organization-level configuration through `,_jsx(_components.code,{children:`POST /settings/storage`}),`. Credentials are stored in Azure Key Vault and are returned masked when the configuration is read back.`]}),`\n`,_jsx(Note,{children:_jsxs(_components.p,{children:[`The dashboard route is not the same contract as the per-API-key endpoint\n`,_jsx(_components.a,{href:`/features/custom-storage#google-cloud-storage`,children:_jsx(_components.code,{children:`POST /user/gcs`})}),`. That\nendpoint names the credentials field `,_jsx(_components.code,{children:`service_account_json`}),` and additionally\naccepts `,_jsx(_components.code,{children:`folder_prefix`}),` and `,_jsx(_components.code,{children:`base_folder`}),`. The dashboard route names the field\n`,_jsx(_components.code,{children:`credentials_json`}),` and accepts `,_jsx(_components.strong,{children:`three fields only`}),`. Do not copy a payload\nfrom one to the other.`]})}),`\n`,_jsx(Heading,{level:`2`,id:`current-limitations`,children:`Current limitations`}),`\n`,_jsx(_components.p,{children:`Read these before you create anything in Google Cloud. Both are properties of the GCS route as it exists today, not Google restrictions.`}),`\n`,_jsxs(Warning,{children:[_jsxs(_components.p,{children:[_jsx(_components.strong,{children:`“Test connection” does not contact Google.`}),` For GCS there is no probe\nobject, no API call, and no credential check of any kind. The test loads the\nsubmitted configuration, confirms that `,_jsx(_components.code,{children:`bucket`}),`, `,_jsx(_components.code,{children:`project_id`}),` and\n`,_jsx(_components.code,{children:`credentials_json`}),` are present and non-empty, and returns:`]}),_jsx(CodeBlock,{filename:``,numberOfLines:`6`,language:`json`,children:_jsx(_components.pre,{className:`shiki shiki-themes github-light-default dark-plus`,style:{backgroundColor:`#ffffff`,\"--shiki-dark-bg\":`#0B0C0E`,color:`#1f2328`,\"--shiki-dark\":`#D4D4D4`},language:`json`,children:_jsxs(_components.code,{language:`json`,numberOfLines:`6`,children:[_jsx(_components.span,{className:`line`,children:_jsx(_components.span,{style:{color:`#1F2328`,\"--shiki-dark\":`#D4D4D4`},children:`{`})}),`\n`,_jsxs(_components.span,{className:`line`,children:[_jsx(_components.span,{style:{color:`#116329`,\"--shiki-dark\":`#9CDCFE`},children:` \"ok\"`}),_jsx(_components.span,{style:{color:`#1F2328`,\"--shiki-dark\":`#D4D4D4`},children:`: `}),_jsx(_components.span,{style:{color:`#0550AE`,\"--shiki-dark\":`#569CD6`},children:`true`}),_jsx(_components.span,{style:{color:`#1F2328`,\"--shiki-dark\":`#D4D4D4`},children:`,`})]}),`\n`,_jsxs(_components.span,{className:`line`,children:[_jsx(_components.span,{style:{color:`#116329`,\"--shiki-dark\":`#9CDCFE`},children:` \"verified\"`}),_jsx(_components.span,{style:{color:`#1F2328`,\"--shiki-dark\":`#D4D4D4`},children:`: `}),_jsx(_components.span,{style:{color:`#0550AE`,\"--shiki-dark\":`#569CD6`},children:`false`}),_jsx(_components.span,{style:{color:`#1F2328`,\"--shiki-dark\":`#D4D4D4`},children:`,`})]}),`\n`,_jsxs(_components.span,{className:`line`,children:[_jsx(_components.span,{style:{color:`#116329`,\"--shiki-dark\":`#9CDCFE`},children:` \"latency_ms\"`}),_jsx(_components.span,{style:{color:`#1F2328`,\"--shiki-dark\":`#D4D4D4`},children:`: `}),_jsx(_components.span,{style:{color:`#0550AE`,\"--shiki-dark\":`#B5CEA8`},children:`0`}),_jsx(_components.span,{style:{color:`#1F2328`,\"--shiki-dark\":`#D4D4D4`},children:`,`})]}),`\n`,_jsxs(_components.span,{className:`line`,children:[_jsx(_components.span,{style:{color:`#116329`,\"--shiki-dark\":`#9CDCFE`},children:` \"note\"`}),_jsx(_components.span,{style:{color:`#1F2328`,\"--shiki-dark\":`#D4D4D4`},children:`: `}),_jsx(_components.span,{style:{color:`#0A3069`,\"--shiki-dark\":`#CE9178`},children:`\"credentials look structurally valid — live round-trip test is not yet supported for provider 'gcs'\"`})]}),`\n`,_jsx(_components.span,{className:`line`,children:_jsx(_components.span,{style:{color:`#1F2328`,\"--shiki-dark\":`#D4D4D4`},children:`}`})}),`\n`]})})}),_jsxs(_components.p,{children:[`The wizard renders that as a `,_jsx(_components.strong,{children:`green`}),` banner reading `,_jsx(_components.em,{children:`“Credentials accepted\nand saved, verified on your first delivery.”`}),` While the request is in flight\nthe button reads `,_jsx(_components.em,{children:`“Writing test object…”`}),`, which is the shared wizard label:\nfor GCS nothing is written anywhere.`]}),_jsxs(_components.p,{children:[`A green result here proves that you filled in three boxes. It does not parse\nthe JSON, and it says nothing about the key, the role, the project or the\nbucket. Every one of those failures surfaces later, on a real job. Verify with\n`,_jsx(_components.a,{href:`#verify-it-for-real`,children:`a real delivery`}),` before you consider the setup done.`]}),_jsxs(_components.p,{children:[`The S3 branch of the same endpoint was recently extended to write, read back,\nopen and abort a multipart upload, and delete. `,_jsx(_components.strong,{children:`None of that applies here.`}),`\nIf you have read the `,_jsx(_components.a,{href:`/storage/aws-s3`,children:`AWS S3`}),` or\n`,_jsx(_components.a,{href:`/storage/cloudflare-r2`,children:`Cloudflare R2`}),` page, discard what it says about the\nprobe: for GCS no packet leaves Tornado.`]})]}),`\n`,_jsx(Warning,{children:_jsxs(_components.p,{children:[_jsx(_components.strong,{children:`The bucket must already exist.`}),` Tornado never creates it, and because the\nconnection test makes no call, a bucket that does not exist is accepted at\nconfiguration time and fails on the first job.`]})}),`\n`,_jsx(Heading,{level:`2`,id:`before-you-start`,children:`Before you start`}),`\n`,_jsx(_components.p,{children:`You need a Google Cloud project and, within it, permission to create a bucket, grant IAM on that bucket, and create a service account key.`}),`\n`,_jsx(Warning,{children:_jsxs(_components.p,{children:[_jsx(_components.strong,{children:`Service account key creation may be blocked by organization policy.`}),` The\n`,_jsx(_components.code,{children:`constraints/iam.disableServiceAccountKeyCreation`}),` constraint prevents key\ncreation in a project, and may be enforced by your organization. If key creation fails with a policy error, an organization\nadministrator must grant an exception for the project before you can continue.\nTornado authenticates with a service account key only: Workload Identity\nFederation, attached service accounts and impersonation are not supported on\nthis route.`]})}),`\n`,_jsx(Heading,{level:`2`,id:`1-create-the-bucket`,children:`1. Create the bucket`}),`\n`,_jsxs(Steps,{children:[_jsx(Step,{title:`Open the bucket creation flow`,children:_jsxs(_components.p,{children:[`In the `,_jsx(_components.a,{href:`https://console.cloud.google.com/storage/browser`,children:`Google Cloud console`}),`,\ngo to `,_jsx(_components.strong,{children:`Cloud Storage › Buckets`}),`, then select `,_jsx(_components.strong,{children:`Create`}),`. Console layouts\nchange; if the entry is not where you expect it, search for “Cloud Storage”\nfrom the top search bar.`]})}),_jsx(Step,{title:`Name it`,children:_jsx(_components.p,{children:`Bucket names are globally unique across all of Google Cloud. Tornado\nadditionally validates the name before every upload: 3 to 63 characters,\nlowercase letters, digits, hyphens and periods only, starting and ending\nwith a letter or digit, no consecutive periods, and not shaped like an IP\naddress. A name Google accepts but Tornado rejects will fail at job time,\nnot at save time.`})}),_jsx(Step,{title:`Choose the location`,children:_jsxs(_components.p,{children:[`Pick a location type and location. See\n`,_jsx(_components.a,{href:`#choosing-a-location`,children:`Choosing a location`}),` below.`]})}),_jsx(Step,{title:`Leave access control as uniform`,children:_jsx(_components.p,{children:`Tornado authorises entirely through IAM and never reads or writes object\nACLs, so uniform bucket-level access is the right setting and is what the\nrest of this page assumes. Note that uniform bucket-level access cannot be\nturned off again once it has been active on a bucket for 90 consecutive\ndays.`})})]}),`\n`,_jsx(_components.p,{children:`Or from the command line:`}),`\n`,_jsx(CodeBlock,{filename:``,numberOfLines:`4`,language:`shellscript`,children:_jsx(_components.pre,{className:`shiki shiki-themes github-light-default dark-plus`,style:{backgroundColor:`#ffffff`,\"--shiki-dark-bg\":`#0B0C0E`,color:`#1f2328`,\"--shiki-dark\":`#D4D4D4`},language:`shellscript`,children:_jsxs(_components.code,{language:`shellscript`,numberOfLines:`4`,children:[_jsxs(_components.span,{className:`line`,children:[_jsx(_components.span,{style:{color:`#953800`,\"--shiki-dark\":`#DCDCAA`},children:`gcloud`}),_jsx(_components.span,{style:{color:`#0A3069`,\"--shiki-dark\":`#CE9178`},children:` storage`}),_jsx(_components.span,{style:{color:`#0A3069`,\"--shiki-dark\":`#CE9178`},children:` buckets`}),_jsx(_components.span,{style:{color:`#0A3069`,\"--shiki-dark\":`#CE9178`},children:` create`}),_jsx(_components.span,{style:{color:`#0A3069`,\"--shiki-dark\":`#CE9178`},children:` gs://tornado-downloads`}),_jsx(_components.span,{style:{color:`#CF222E`,\"--shiki-dark\":`#D7BA7D`},children:` \\\\`})]}),`\n`,_jsxs(_components.span,{className:`line`,children:[_jsx(_components.span,{style:{color:`#0550AE`,\"--shiki-dark\":`#569CD6`},children:` --project=my-gcp-project`}),_jsx(_components.span,{style:{color:`#CF222E`,\"--shiki-dark\":`#D7BA7D`},children:` \\\\`})]}),`\n`,_jsxs(_components.span,{className:`line`,children:[_jsx(_components.span,{style:{color:`#0550AE`,\"--shiki-dark\":`#569CD6`},children:` --location=europe-west1`}),_jsx(_components.span,{style:{color:`#CF222E`,\"--shiki-dark\":`#D7BA7D`},children:` \\\\`})]}),`\n`,_jsx(_components.span,{className:`line`,children:_jsx(_components.span,{style:{color:`#0550AE`,\"--shiki-dark\":`#569CD6`},children:` --uniform-bucket-level-access`})}),`\n`]})})}),`\n`,_jsx(Heading,{level:`3`,id:`choosing-a-location`,children:`Choosing a location`}),`\n`,_jsx(_components.p,{children:`Google offers regions, dual-regions and multi-regions. Choose according to your availability requirements, data-location constraints and the provider’s current storage, replication and transfer prices. Re-running a download is only an option while the original media remains available.`}),`\n`,_jsxs(_components.p,{children:[`Compare upload duration and recipient download latency using real deliveries to candidate locations. The GCS dashboard test returns `,_jsx(_components.code,{children:`latency_ms: 0`}),` without contacting Google, so it cannot measure distance to Tornado’s workers.`]}),`\n`,_jsx(Heading,{level:`2`,id:`2-create-the-service-account`,children:`2. Create the service account`}),`\n`,_jsxs(Steps,{children:[_jsx(Step,{title:`Open Service Accounts`,children:_jsxs(_components.p,{children:[`Go to `,_jsx(_components.strong,{children:`IAM \u0026 Admin › Service Accounts`}),` in the\n`,_jsx(_components.a,{href:`https://console.cloud.google.com/iam-admin/serviceaccounts`,children:`console`}),`, select\nyour project, then select `,_jsx(_components.strong,{children:`Create service account`}),`.`]})}),_jsx(Step,{title:`Name it for this purpose only`,children:_jsxs(_components.p,{children:[`Something like `,_jsx(_components.code,{children:`tornado-delivery`}),`. Use one service account per delivery\ndestination so you can revoke it without affecting anything else.`]})}),_jsx(Step,{title:`Skip the project-level role`,children:_jsxs(_components.p,{children:[`The creation flow offers an optional step titled along the lines of “Grant\nthis service account access to project”. `,_jsx(_components.strong,{children:`Skip it.`}),` Granting a role there\napplies it to every bucket in the project, present and future. You will\ngrant the role on one bucket in the next step.`]})}),_jsx(Step,{title:`Copy the service account email`,children:_jsxs(_components.p,{children:[`It looks like\n`,_jsx(_components.code,{children:`tornado-delivery@my-gcp-project.iam.gserviceaccount.com`}),`. You need it for\nthe IAM grant.`]})})]}),`\n`,_jsx(CodeBlock,{filename:``,numberOfLines:`3`,language:`shellscript`,children:_jsx(_components.pre,{className:`shiki shiki-themes github-light-default dark-plus`,style:{backgroundColor:`#ffffff`,\"--shiki-dark-bg\":`#0B0C0E`,color:`#1f2328`,\"--shiki-dark\":`#D4D4D4`},language:`shellscript`,children:_jsxs(_components.code,{language:`shellscript`,numberOfLines:`3`,children:[_jsxs(_components.span,{className:`line`,children:[_jsx(_components.span,{style:{color:`#953800`,\"--shiki-dark\":`#DCDCAA`},children:`gcloud`}),_jsx(_components.span,{style:{color:`#0A3069`,\"--shiki-dark\":`#CE9178`},children:` iam`}),_jsx(_components.span,{style:{color:`#0A3069`,\"--shiki-dark\":`#CE9178`},children:` service-accounts`}),_jsx(_components.span,{style:{color:`#0A3069`,\"--shiki-dark\":`#CE9178`},children:` create`}),_jsx(_components.span,{style:{color:`#0A3069`,\"--shiki-dark\":`#CE9178`},children:` tornado-delivery`}),_jsx(_components.span,{style:{color:`#CF222E`,\"--shiki-dark\":`#D7BA7D`},children:` \\\\`})]}),`\n`,_jsxs(_components.span,{className:`line`,children:[_jsx(_components.span,{style:{color:`#0550AE`,\"--shiki-dark\":`#569CD6`},children:` --project=my-gcp-project`}),_jsx(_components.span,{style:{color:`#CF222E`,\"--shiki-dark\":`#D7BA7D`},children:` \\\\`})]}),`\n`,_jsxs(_components.span,{className:`line`,children:[_jsx(_components.span,{style:{color:`#0550AE`,\"--shiki-dark\":`#569CD6`},children:` --display-name=`}),_jsx(_components.span,{style:{color:`#0A3069`,\"--shiki-dark\":`#CE9178`},children:`\"Tornado delivery\"`})]}),`\n`]})})}),`\n`,_jsx(Heading,{level:`2`,id:`3-grant-the-role-on-the-bucket`,children:`3. Grant the role on the bucket`}),`\n`,_jsx(Heading,{level:`3`,id:`what-tornado-actually-does-in-your-bucket`,children:`What Tornado actually does in your bucket`}),`\n`,_jsx(_components.p,{children:`The role has to cover every operation the delivery path performs, not just the upload. The default file-upload strategy depends on size and concurrency. Large files can use parallel composite upload, while an optional worker mode uses XML multipart. Streaming uploads have their own strategy; a small dashboard probe does not exercise these paths.`}),`\n`,_jsxs(Table,{children:[_jsx(_components.thead,{children:_jsxs(_components.tr,{children:[_jsx(_components.th,{children:`Operation`}),_jsx(_components.th,{children:`When it happens`}),_jsx(_components.th,{children:`IAM permission`})]})}),_jsxs(_components.tbody,{children:[_jsxs(_components.tr,{children:[_jsx(_components.td,{children:`Single-request upload`}),_jsx(_components.td,{children:`Files under 5 MiB in the default file path`}),_jsx(_components.td,{children:_jsx(_components.code,{children:`storage.objects.create`})})]}),_jsxs(_components.tr,{children:[_jsx(_components.td,{children:`Resumable upload`}),_jsx(_components.td,{children:`Files of 5 MiB or more when composite/XML is not selected`}),_jsx(_components.td,{children:_jsx(_components.code,{children:`storage.objects.create`})})]}),_jsxs(_components.tr,{children:[_jsx(_components.td,{children:`Parallel composite upload: part uploads`}),_jsx(_components.td,{children:`Files of at least 100 MiB when composite upload is enabled and concurrency exceeds one`}),_jsx(_components.td,{children:_jsx(_components.code,{children:`storage.objects.create`})})]}),_jsxs(_components.tr,{children:[_jsxs(_components.td,{children:[`Parallel composite upload: `,_jsx(_components.code,{children:`objects.compose`})]}),_jsx(_components.td,{children:`Same, once the parts are uploaded`}),_jsxs(_components.td,{children:[_jsx(_components.code,{children:`storage.objects.create`}),` `,_jsx(_components.strong,{children:`and`}),` `,_jsx(_components.code,{children:`storage.objects.get`})]})]}),_jsxs(_components.tr,{children:[_jsx(_components.td,{children:`Deleting the temporary parts after a compose`}),_jsx(_components.td,{children:`After every composite upload`}),_jsx(_components.td,{children:_jsx(_components.code,{children:`storage.objects.delete`})})]}),_jsxs(_components.tr,{children:[_jsx(_components.td,{children:`Overwriting an object that already exists`}),_jsx(_components.td,{children:`Re-running a job that writes the same key`}),_jsxs(_components.td,{children:[_jsx(_components.code,{children:`storage.objects.create`}),` `,_jsx(_components.strong,{children:`and`}),` `,_jsx(_components.code,{children:`storage.objects.delete`})]})]}),_jsxs(_components.tr,{children:[_jsx(_components.td,{children:`Deleting a delivered object`}),_jsx(_components.td,{children:`Cleanup operations`}),_jsx(_components.td,{children:_jsx(_components.code,{children:`storage.objects.delete`})})]}),_jsxs(_components.tr,{children:[_jsxs(_components.td,{children:[`Credential probe on `,_jsx(_components.code,{children:`POST /user/gcs`})]}),_jsx(_components.td,{children:`Only on the API route, at configuration time`}),_jsxs(_components.td,{children:[_jsx(_components.code,{children:`storage.objects.create`}),`, then `,_jsx(_components.code,{children:`storage.objects.delete`}),` to clean up after itself`]})]}),_jsxs(_components.tr,{children:[_jsx(_components.td,{children:`Serving a signed download URL`}),_jsx(_components.td,{children:`When someone opens a delivered link`}),_jsx(_components.td,{children:_jsx(_components.code,{children:`storage.objects.get`})})]})]})]}),`\n`,_jsx(_components.p,{children:`Two of these deserve spelling out.`}),`\n`,_jsxs(_components.p,{children:[_jsx(_components.strong,{children:`Composite uploads read what they wrote.`}),` When selected, the composite path uploads parts and merges them server-side with `,_jsx(_components.code,{children:`objects.compose`}),`, in batches of up to 32 sources. Google’s reference for that method lists `,_jsx(_components.code,{children:`storage.objects.create`}),` and `,_jsx(_components.code,{children:`storage.objects.get`}),` as always required, and `,_jsx(_components.code,{children:`storage.objects.delete`}),` as required “if the new composite object overwrites an existing object”. Tornado then deletes the temporary parts, which are named after the destination key with a `,_jsx(_components.code,{children:`.tornado.tmp.\u003cuuid>.\u003cnnnnn>`}),` suffix.`]}),`\n`,_jsx(Note,{children:_jsxs(_components.p,{children:[`Two bucket settings interact badly with those temporary parts. On a bucket with `,_jsx(_components.strong,{children:`object versioning`}),` enabled, each temp part leaves a noncurrent version behind when it is deleted, billed until a lifecycle rule reaps it. On a bucket with a `,_jsx(_components.strong,{children:`retention policy or bucket lock`}),`, the temp-part deletes fail outright, and no role fixes that: the bucket configuration is the blocker, not the credential.`]})}),`\n`,_jsxs(_components.p,{children:[_jsx(_components.strong,{children:`Signed URLs are signed locally, but read remotely.`}),` Tornado builds V4 signed GET URLs offline using the private key in your JSON file, so signing itself does not call Google or validate object-read permissions. The permission check happens when the recipient opens the link, and the request is authorised as the signing service account. Without `,_jsx(_components.code,{children:`storage.objects.get`}),`, every signed link returns 403 even though the file is sitting in the bucket.`]}),`\n`,_jsx(Heading,{level:`3`,id:`minimum`,children:`Minimum`}),`\n`,_jsx(_components.p,{children:`For the default JSON/composite paths, allow these object operations:`}),`\n`,_jsx(CodeBlock,{numberOfLines:`4`,language:`text`,children:_jsx(_components.pre,{className:`shiki shiki-themes github-light-default dark-plus`,style:{backgroundColor:`#ffffff`,\"--shiki-dark-bg\":`#0B0C0E`,color:`#1f2328`,\"--shiki-dark\":`#D4D4D4`},language:`text`,children:_jsxs(_components.code,{language:`text`,numberOfLines:`4`,children:[_jsx(_components.span,{className:`line`,children:_jsx(_components.span,{children:`storage.objects.create # every upload: media, resumable, composite parts, the compose destination, the probe object`})}),`\n`,_jsx(_components.span,{className:`line`,children:_jsx(_components.span,{children:`storage.objects.get # signed download URLs, and objects.compose reading its own source parts`})}),`\n`,_jsx(_components.span,{className:`line`,children:_jsx(_components.span,{children:`storage.objects.delete # temp-part cleanup, probe cleanup, and overwrite-on-retry`})}),`\n`,_jsx(_components.span,{className:`line`,children:_jsx(_components.span,{})})]})})}),`\n`,_jsxs(_components.p,{children:[`A `,_jsx(_components.strong,{children:`custom role`}),` granted `,_jsx(_components.strong,{children:`on this bucket`}),` avoids giving the service account access to other buckets in the project. Inherited project permissions can also authorize access, but are broader.`]}),`\n`,_jsxs(_components.p,{children:[`The default JSON/composite path does not enumerate the bucket. Its operation set does not require `,_jsx(_components.code,{children:`storage.objects.list`}),`, `,_jsx(_components.code,{children:`storage.objects.update`}),`, `,_jsx(_components.code,{children:`storage.buckets.get`}),` or `,_jsx(_components.code,{children:`storage.buckets.list`}),`.`]}),`\n`,_jsx(Note,{children:_jsxs(_components.p,{children:[`The optional worker mode `,_jsx(_components.code,{children:`GCS_XML_MULTIPART=1`}),` uses XML multipart with the same service-account credential. That mode additionally needs the multipart permissions for creating and aborting uploads. Confirm the deployed upload mode and its IAM requirements before adopting a three-permission custom role. The dashboard does not expose an XML toggle or accept HMAC keys.`]})}),`\n`,_jsx(Heading,{level:`3`,id:`recommended`,children:`Recommended`}),`\n`,_jsx(_components.p,{children:`A predefined role can be easier to maintain than a custom role; inspect its current permission list before granting it:`}),`\n`,_jsxs(Table,{children:[_jsx(_components.thead,{children:_jsxs(_components.tr,{children:[_jsx(_components.th,{children:`Role`}),_jsx(_components.th,{children:`Why it is worth the extra`})]})}),_jsx(_components.tbody,{children:_jsxs(_components.tr,{children:[_jsx(_components.td,{children:_jsx(_components.strong,{children:_jsx(_components.code,{children:`roles/storage.objectUser`})})}),_jsx(_components.td,{children:`Covers object creation, reading and deletion, with additional permissions beyond this default path. Scope the grant to the destination bucket and verify its multipart permissions if the XML mode is enabled`})]})})]}),`\n`,_jsxs(_components.p,{children:[_jsx(_components.code,{children:`roles/storage.objectAdmin`}),` also works and is the answer you will find in most older write-ups; it is a slightly wider version of the same idea. `,_jsx(_components.code,{children:`roles/storage.objectViewer`}),` and `,_jsx(_components.code,{children:`roles/storage.objectCreator`}),` together are still not enough, because neither carries `,_jsx(_components.code,{children:`delete`}),`.`]}),`\n`,_jsx(_components.p,{children:`Prefer granting the selected role on the destination bucket rather than the whole project.`}),`\n`,_jsxs(Warning,{children:[_jsxs(_components.p,{children:[_jsxs(_components.strong,{children:[_jsx(_components.code,{children:`roles/storage.objectCreator`}),` is not enough.`]}),` It does not provide the\nobject-read and deletion permissions this delivery flow needs. The current\nonboarding hint recommends `,_jsx(_components.code,{children:`roles/storage.objectAdmin`}),`; a narrower role must\nstill cover every operation used by your upload mode.`]}),_jsxs(_components.p,{children:[`With `,_jsx(_components.code,{children:`objectCreator`}),` alone, small files written to object names that do not\nyet exist will appear to work, and then:`]}),_jsxs(_components.ul,{children:[`\n`,_jsxs(_components.li,{children:[`A composite upload fails when `,_jsx(_components.code,{children:`objects.compose`}),` needs\n`,_jsx(_components.code,{children:`storage.objects.get`}),`. The default file path selects composite upload from 100 MiB when enabled and parallelism is available.`]}),`\n`,_jsxs(_components.li,{children:[`Every signed download URL returns 403, because the reader’s GET requires\n`,_jsx(_components.code,{children:`storage.objects.get`}),`.`]}),`\n`,_jsxs(_components.li,{children:[`Re-delivering to an object name that already exists fails, because\noverwriting requires `,_jsx(_components.code,{children:`storage.objects.delete`}),`.`]}),`\n`,_jsxs(_components.li,{children:[`Temporary composite parts and the `,_jsx(_components.code,{children:`verify_credentials.txt`}),` probe object are\nleft behind in the bucket, because the cleanup deletes are rejected and\ncleanup failures are not surfaced.`]}),`\n`]}),_jsx(_components.p,{children:`Grant a bucket-scoped role covering the operations of your deployed upload\nmode, and validate both a real delivery and a recipient download.`})]}),`\n`,_jsx(Heading,{level:`3`,id:`granting-it`,children:`Granting it`}),`\n`,_jsxs(Steps,{children:[_jsx(Step,{title:`Open the bucket's Permissions tab`,children:_jsxs(_components.p,{children:[`In `,_jsx(_components.strong,{children:`Cloud Storage › Buckets`}),`, select the bucket name, then select the\n`,_jsx(_components.strong,{children:`Permissions`}),` tab.`]})}),_jsx(Step,{title:`Grant access`,children:_jsxs(_components.p,{children:[`Select `,_jsx(_components.strong,{children:`Grant access`}),`, paste the service account email into `,_jsx(_components.strong,{children:`New\nprincipals`}),`, and choose `,_jsx(_components.strong,{children:`Storage Object User`}),` from `,_jsx(_components.strong,{children:`Select a role`}),`, or\nthe custom role you created.`]})}),_jsx(Step,{title:`Save`,children:_jsxs(_components.p,{children:[`Select `,_jsx(_components.strong,{children:`Save`}),`. The grant applies to this bucket only.`]})})]}),`\n`,_jsx(CodeBlock,{filename:``,numberOfLines:`3`,language:`shellscript`,children:_jsx(_components.pre,{className:`shiki shiki-themes github-light-default dark-plus`,style:{backgroundColor:`#ffffff`,\"--shiki-dark-bg\":`#0B0C0E`,color:`#1f2328`,\"--shiki-dark\":`#D4D4D4`},language:`shellscript`,children:_jsxs(_components.code,{language:`shellscript`,numberOfLines:`3`,children:[_jsxs(_components.span,{className:`line`,children:[_jsx(_components.span,{style:{color:`#953800`,\"--shiki-dark\":`#DCDCAA`},children:`gcloud`}),_jsx(_components.span,{style:{color:`#0A3069`,\"--shiki-dark\":`#CE9178`},children:` storage`}),_jsx(_components.span,{style:{color:`#0A3069`,\"--shiki-dark\":`#CE9178`},children:` buckets`}),_jsx(_components.span,{style:{color:`#0A3069`,\"--shiki-dark\":`#CE9178`},children:` add-iam-policy-binding`}),_jsx(_components.span,{style:{color:`#0A3069`,\"--shiki-dark\":`#CE9178`},children:` gs://tornado-downloads`}),_jsx(_components.span,{style:{color:`#CF222E`,\"--shiki-dark\":`#D7BA7D`},children:` \\\\`})]}),`\n`,_jsxs(_components.span,{className:`line`,children:[_jsx(_components.span,{style:{color:`#0550AE`,\"--shiki-dark\":`#569CD6`},children:` --member=serviceAccount:tornado-delivery@my-gcp-project.iam.gserviceaccount.com`}),_jsx(_components.span,{style:{color:`#CF222E`,\"--shiki-dark\":`#D7BA7D`},children:` \\\\`})]}),`\n`,_jsx(_components.span,{className:`line`,children:_jsx(_components.span,{style:{color:`#0550AE`,\"--shiki-dark\":`#569CD6`},children:` --role=roles/storage.objectUser`})}),`\n`]})})}),`\n`,_jsx(Tip,{children:_jsxs(_components.p,{children:[`Check the grant landed on the bucket and not the project:\n`,_jsx(_components.code,{children:`gcloud storage buckets get-iam-policy gs://tornado-downloads`}),` should list your\nservice account, and `,_jsx(_components.code,{children:`gcloud projects get-iam-policy my-gcp-project`}),` should\nnot.`]})}),`\n`,_jsx(Heading,{level:`2`,id:`4-create-and-download-the-json-key`,children:`4. Create and download the JSON key`}),`\n`,_jsxs(Steps,{children:[_jsx(Step,{title:`Open the Keys tab`,children:_jsxs(_components.p,{children:[`In `,_jsx(_components.strong,{children:`IAM \u0026 Admin › Service Accounts`}),`, select the service account email, then\nselect the `,_jsx(_components.strong,{children:`Keys`}),` tab.`]})}),_jsx(Step,{title:`Create a JSON key`,children:_jsxs(_components.p,{children:[`Select `,_jsx(_components.strong,{children:`Add key › Create new key`}),`, choose `,_jsx(_components.strong,{children:`JSON`}),`, and select `,_jsx(_components.strong,{children:`Create`}),`.\nThe file downloads immediately.`]})}),_jsx(Step,{title:`Store it somewhere sane`,children:_jsx(_components.p,{children:`The private key cannot be downloaded again. Treat the file as a credential:\nkeep it out of version control, and delete your local copy once it is in the\ndashboard.`})})]}),`\n`,_jsx(CodeBlock,{filename:``,numberOfLines:`2`,language:`shellscript`,children:_jsx(_components.pre,{className:`shiki shiki-themes github-light-default dark-plus`,style:{backgroundColor:`#ffffff`,\"--shiki-dark-bg\":`#0B0C0E`,color:`#1f2328`,\"--shiki-dark\":`#D4D4D4`},language:`shellscript`,children:_jsxs(_components.code,{language:`shellscript`,numberOfLines:`2`,children:[_jsxs(_components.span,{className:`line`,children:[_jsx(_components.span,{style:{color:`#953800`,\"--shiki-dark\":`#DCDCAA`},children:`gcloud`}),_jsx(_components.span,{style:{color:`#0A3069`,\"--shiki-dark\":`#CE9178`},children:` iam`}),_jsx(_components.span,{style:{color:`#0A3069`,\"--shiki-dark\":`#CE9178`},children:` service-accounts`}),_jsx(_components.span,{style:{color:`#0A3069`,\"--shiki-dark\":`#CE9178`},children:` keys`}),_jsx(_components.span,{style:{color:`#0A3069`,\"--shiki-dark\":`#CE9178`},children:` create`}),_jsx(_components.span,{style:{color:`#0A3069`,\"--shiki-dark\":`#CE9178`},children:` tornado-delivery.json`}),_jsx(_components.span,{style:{color:`#CF222E`,\"--shiki-dark\":`#D7BA7D`},children:` \\\\`})]}),`\n`,_jsx(_components.span,{className:`line`,children:_jsx(_components.span,{style:{color:`#0550AE`,\"--shiki-dark\":`#569CD6`},children:` --iam-account=tornado-delivery@my-gcp-project.iam.gserviceaccount.com`})}),`\n`]})})}),`\n`,_jsxs(_components.p,{children:[`The file is a JSON object beginning `,_jsx(_components.code,{children:`{\"type\": \"service_account\", ...}`}),`, roughly 2.3 KB. Tornado requires `,_jsx(_components.code,{children:`type`}),` to be exactly `,_jsx(_components.code,{children:`service_account`}),` and requires `,_jsx(_components.code,{children:`project_id`}),`, `,_jsx(_components.code,{children:`private_key`}),` and `,_jsx(_components.code,{children:`client_email`}),` to be present.`]}),`\n`,_jsx(Heading,{level:`2`,id:`5-enter-the-three-fields`,children:`5. Enter the three fields`}),`\n`,_jsxs(_components.p,{children:[`Open the dashboard, choose `,_jsx(_components.strong,{children:`Google Cloud Storage`}),` as the destination, and fill in the fields. Labels differ between the onboarding wizard and the settings page; both write the same three API fields.`]}),`\n`,_jsxs(Table,{children:[_jsx(_components.thead,{children:_jsxs(_components.tr,{children:[_jsx(_components.th,{children:`Onboarding label`}),_jsx(_components.th,{children:`Settings label`}),_jsx(_components.th,{children:`API field`}),_jsx(_components.th,{children:`What to paste`})]})}),_jsxs(_components.tbody,{children:[_jsxs(_components.tr,{children:[_jsx(_components.td,{children:`Bucket name`}),_jsx(_components.td,{children:`Bucket`}),_jsx(_components.td,{children:_jsx(_components.code,{children:`bucket`})}),_jsxs(_components.td,{children:[`The bucket name only, for example `,_jsx(_components.code,{children:`tornado-downloads`}),`. No `,_jsx(_components.code,{children:`gs://`}),` prefix, no URL, no path segments.`]})]}),_jsxs(_components.tr,{children:[_jsx(_components.td,{children:`Project ID`}),_jsx(_components.td,{children:`Project ID`}),_jsx(_components.td,{children:_jsx(_components.code,{children:`project_id`})}),_jsxs(_components.td,{children:[`The project `,_jsx(_components.strong,{children:`ID`}),`, for example `,_jsx(_components.code,{children:`my-gcp-project`}),`. Not the project name and not the project number.`]})]}),_jsxs(_components.tr,{children:[_jsx(_components.td,{children:`Service account key (JSON)`}),_jsx(_components.td,{children:`Credentials JSON`}),_jsx(_components.td,{children:_jsx(_components.code,{children:`credentials_json`})}),_jsxs(_components.td,{children:[`The entire contents of the downloaded `,_jsx(_components.code,{children:`.json`}),` file, as a single value, opening brace to closing brace.`]})]})]})]}),`\n`,_jsx(Heading,{level:`3`,id:`pasting-the-key`,children:`Pasting the key`}),`\n`,_jsx(_components.p,{children:`Open the downloaded file in a text editor, select all, and paste. The field is a textarea and takes the raw file content, newlines and all. Do not reformat it.`}),`\n`,_jsx(Warning,{children:_jsxs(_components.p,{children:[_jsx(_components.strong,{children:`Do not base64-encode the key for this field, and do not escape it by hand.`}),`\nTornado parses the stored value directly as JSON. A base64 blob fails with\n`,_jsx(_components.em,{children:`“Invalid JSON in service account credentials”`}),`, and so does a value where the\n`,_jsx(_components.code,{children:`private_key`}),` newlines have been mangled by a copy that went through an\nintermediate tool. If you are constructing the API payload programmatically\ninstead of pasting, let your JSON serialiser escape the string; do not do it\nyourself.`]})}),`\n`,_jsx(_components.p,{children:`The equivalent payload:`}),`\n`,_jsx(CodeBlock,{filename:``,numberOfLines:`8`,language:`json`,children:_jsx(_components.pre,{className:`shiki shiki-themes github-light-default dark-plus`,style:{backgroundColor:`#ffffff`,\"--shiki-dark-bg\":`#0B0C0E`,color:`#1f2328`,\"--shiki-dark\":`#D4D4D4`},language:`json`,children:_jsxs(_components.code,{language:`json`,numberOfLines:`8`,children:[_jsx(_components.span,{className:`line`,children:_jsx(_components.span,{style:{color:`#1F2328`,\"--shiki-dark\":`#D4D4D4`},children:`{`})}),`\n`,_jsxs(_components.span,{className:`line`,children:[_jsx(_components.span,{style:{color:`#116329`,\"--shiki-dark\":`#9CDCFE`},children:` \"provider\"`}),_jsx(_components.span,{style:{color:`#1F2328`,\"--shiki-dark\":`#D4D4D4`},children:`: `}),_jsx(_components.span,{style:{color:`#0A3069`,\"--shiki-dark\":`#CE9178`},children:`\"gcs\"`}),_jsx(_components.span,{style:{color:`#1F2328`,\"--shiki-dark\":`#D4D4D4`},children:`,`})]}),`\n`,_jsxs(_components.span,{className:`line`,children:[_jsx(_components.span,{style:{color:`#116329`,\"--shiki-dark\":`#9CDCFE`},children:` \"gcs\"`}),_jsx(_components.span,{style:{color:`#1F2328`,\"--shiki-dark\":`#D4D4D4`},children:`: {`})]}),`\n`,_jsxs(_components.span,{className:`line`,children:[_jsx(_components.span,{style:{color:`#116329`,\"--shiki-dark\":`#9CDCFE`},children:` \"bucket\"`}),_jsx(_components.span,{style:{color:`#1F2328`,\"--shiki-dark\":`#D4D4D4`},children:`: `}),_jsx(_components.span,{style:{color:`#0A3069`,\"--shiki-dark\":`#CE9178`},children:`\"tornado-downloads\"`}),_jsx(_components.span,{style:{color:`#1F2328`,\"--shiki-dark\":`#D4D4D4`},children:`,`})]}),`\n`,_jsxs(_components.span,{className:`line`,children:[_jsx(_components.span,{style:{color:`#116329`,\"--shiki-dark\":`#9CDCFE`},children:` \"project_id\"`}),_jsx(_components.span,{style:{color:`#1F2328`,\"--shiki-dark\":`#D4D4D4`},children:`: `}),_jsx(_components.span,{style:{color:`#0A3069`,\"--shiki-dark\":`#CE9178`},children:`\"my-gcp-project\"`}),_jsx(_components.span,{style:{color:`#1F2328`,\"--shiki-dark\":`#D4D4D4`},children:`,`})]}),`\n`,_jsxs(_components.span,{className:`line`,children:[_jsx(_components.span,{style:{color:`#116329`,\"--shiki-dark\":`#9CDCFE`},children:` \"credentials_json\"`}),_jsx(_components.span,{style:{color:`#1F2328`,\"--shiki-dark\":`#D4D4D4`},children:`: `}),_jsx(_components.span,{style:{color:`#0A3069`,\"--shiki-dark\":`#CE9178`},children:`\"{`}),_jsx(_components.span,{style:{color:`#CF222E`,\"--shiki-dark\":`#D7BA7D`},children:`\\\\\"`}),_jsx(_components.span,{style:{color:`#0A3069`,\"--shiki-dark\":`#CE9178`},children:`type`}),_jsx(_components.span,{style:{color:`#CF222E`,\"--shiki-dark\":`#D7BA7D`},children:`\\\\\"`}),_jsx(_components.span,{style:{color:`#0A3069`,\"--shiki-dark\":`#CE9178`},children:`:`}),_jsx(_components.span,{style:{color:`#CF222E`,\"--shiki-dark\":`#D7BA7D`},children:`\\\\\"`}),_jsx(_components.span,{style:{color:`#0A3069`,\"--shiki-dark\":`#CE9178`},children:`service_account`}),_jsx(_components.span,{style:{color:`#CF222E`,\"--shiki-dark\":`#D7BA7D`},children:`\\\\\"`}),_jsx(_components.span,{style:{color:`#0A3069`,\"--shiki-dark\":`#CE9178`},children:`,`}),_jsx(_components.span,{style:{color:`#CF222E`,\"--shiki-dark\":`#D7BA7D`},children:`\\\\\"`}),_jsx(_components.span,{style:{color:`#0A3069`,\"--shiki-dark\":`#CE9178`},children:`project_id`}),_jsx(_components.span,{style:{color:`#CF222E`,\"--shiki-dark\":`#D7BA7D`},children:`\\\\\"`}),_jsx(_components.span,{style:{color:`#0A3069`,\"--shiki-dark\":`#CE9178`},children:`:`}),_jsx(_components.span,{style:{color:`#CF222E`,\"--shiki-dark\":`#D7BA7D`},children:`\\\\\"`}),_jsx(_components.span,{style:{color:`#0A3069`,\"--shiki-dark\":`#CE9178`},children:`my-gcp-project`}),_jsx(_components.span,{style:{color:`#CF222E`,\"--shiki-dark\":`#D7BA7D`},children:`\\\\\"`}),_jsx(_components.span,{style:{color:`#0A3069`,\"--shiki-dark\":`#CE9178`},children:`,`}),_jsx(_components.span,{style:{color:`#CF222E`,\"--shiki-dark\":`#D7BA7D`},children:`\\\\\"`}),_jsx(_components.span,{style:{color:`#0A3069`,\"--shiki-dark\":`#CE9178`},children:`private_key_id`}),_jsx(_components.span,{style:{color:`#CF222E`,\"--shiki-dark\":`#D7BA7D`},children:`\\\\\"`}),_jsx(_components.span,{style:{color:`#0A3069`,\"--shiki-dark\":`#CE9178`},children:`:`}),_jsx(_components.span,{style:{color:`#CF222E`,\"--shiki-dark\":`#D7BA7D`},children:`\\\\\"`}),_jsx(_components.span,{style:{color:`#0A3069`,\"--shiki-dark\":`#CE9178`},children:`...`}),_jsx(_components.span,{style:{color:`#CF222E`,\"--shiki-dark\":`#D7BA7D`},children:`\\\\\"`}),_jsx(_components.span,{style:{color:`#0A3069`,\"--shiki-dark\":`#CE9178`},children:`,`}),_jsx(_components.span,{style:{color:`#CF222E`,\"--shiki-dark\":`#D7BA7D`},children:`\\\\\"`}),_jsx(_components.span,{style:{color:`#0A3069`,\"--shiki-dark\":`#CE9178`},children:`private_key`}),_jsx(_components.span,{style:{color:`#CF222E`,\"--shiki-dark\":`#D7BA7D`},children:`\\\\\"`}),_jsx(_components.span,{style:{color:`#0A3069`,\"--shiki-dark\":`#CE9178`},children:`:`}),_jsx(_components.span,{style:{color:`#CF222E`,\"--shiki-dark\":`#D7BA7D`},children:`\\\\\"`}),_jsx(_components.span,{style:{color:`#0A3069`,\"--shiki-dark\":`#CE9178`},children:`-----BEGIN PRIVATE KEY-----`}),_jsx(_components.span,{style:{color:`#CF222E`,\"--shiki-dark\":`#D7BA7D`},children:`\\\\\\\\`}),_jsx(_components.span,{style:{color:`#0A3069`,\"--shiki-dark\":`#CE9178`},children:`n...`}),_jsx(_components.span,{style:{color:`#CF222E`,\"--shiki-dark\":`#D7BA7D`},children:`\\\\\\\\`}),_jsx(_components.span,{style:{color:`#0A3069`,\"--shiki-dark\":`#CE9178`},children:`n-----END PRIVATE KEY-----`}),_jsx(_components.span,{style:{color:`#CF222E`,\"--shiki-dark\":`#D7BA7D`},children:`\\\\\\\\`}),_jsx(_components.span,{style:{color:`#0A3069`,\"--shiki-dark\":`#CE9178`},children:`n`}),_jsx(_components.span,{style:{color:`#CF222E`,\"--shiki-dark\":`#D7BA7D`},children:`\\\\\"`}),_jsx(_components.span,{style:{color:`#0A3069`,\"--shiki-dark\":`#CE9178`},children:`,`}),_jsx(_components.span,{style:{color:`#CF222E`,\"--shiki-dark\":`#D7BA7D`},children:`\\\\\"`}),_jsx(_components.span,{style:{color:`#0A3069`,\"--shiki-dark\":`#CE9178`},children:`client_email`}),_jsx(_components.span,{style:{color:`#CF222E`,\"--shiki-dark\":`#D7BA7D`},children:`\\\\\"`}),_jsx(_components.span,{style:{color:`#0A3069`,\"--shiki-dark\":`#CE9178`},children:`:`}),_jsx(_components.span,{style:{color:`#CF222E`,\"--shiki-dark\":`#D7BA7D`},children:`\\\\\"`}),_jsx(_components.span,{style:{color:`#0A3069`,\"--shiki-dark\":`#CE9178`},children:`tornado-delivery@my-gcp-project.iam.gserviceaccount.com`}),_jsx(_components.span,{style:{color:`#CF222E`,\"--shiki-dark\":`#D7BA7D`},children:`\\\\\"`}),_jsx(_components.span,{style:{color:`#0A3069`,\"--shiki-dark\":`#CE9178`},children:`,`}),_jsx(_components.span,{style:{color:`#CF222E`,\"--shiki-dark\":`#D7BA7D`},children:`\\\\\"`}),_jsx(_components.span,{style:{color:`#0A3069`,\"--shiki-dark\":`#CE9178`},children:`client_id`}),_jsx(_components.span,{style:{color:`#CF222E`,\"--shiki-dark\":`#D7BA7D`},children:`\\\\\"`}),_jsx(_components.span,{style:{color:`#0A3069`,\"--shiki-dark\":`#CE9178`},children:`:`}),_jsx(_components.span,{style:{color:`#CF222E`,\"--shiki-dark\":`#D7BA7D`},children:`\\\\\"`}),_jsx(_components.span,{style:{color:`#0A3069`,\"--shiki-dark\":`#CE9178`},children:`...`}),_jsx(_components.span,{style:{color:`#CF222E`,\"--shiki-dark\":`#D7BA7D`},children:`\\\\\"`}),_jsx(_components.span,{style:{color:`#0A3069`,\"--shiki-dark\":`#CE9178`},children:`,`}),_jsx(_components.span,{style:{color:`#CF222E`,\"--shiki-dark\":`#D7BA7D`},children:`\\\\\"`}),_jsx(_components.span,{style:{color:`#0A3069`,\"--shiki-dark\":`#CE9178`},children:`token_uri`}),_jsx(_components.span,{style:{color:`#CF222E`,\"--shiki-dark\":`#D7BA7D`},children:`\\\\\"`}),_jsx(_components.span,{style:{color:`#0A3069`,\"--shiki-dark\":`#CE9178`},children:`:`}),_jsx(_components.span,{style:{color:`#CF222E`,\"--shiki-dark\":`#D7BA7D`},children:`\\\\\"`}),_jsx(_components.span,{style:{color:`#0A3069`,\"--shiki-dark\":`#CE9178`},children:`https://oauth2.googleapis.com/token`}),_jsx(_components.span,{style:{color:`#CF222E`,\"--shiki-dark\":`#D7BA7D`},children:`\\\\\"`}),_jsx(_components.span,{style:{color:`#0A3069`,\"--shiki-dark\":`#CE9178`},children:`}\"`})]}),`\n`,_jsx(_components.span,{className:`line`,children:_jsx(_components.span,{style:{color:`#1F2328`,\"--shiki-dark\":`#D4D4D4`},children:` }`})}),`\n`,_jsx(_components.span,{className:`line`,children:_jsx(_components.span,{style:{color:`#1F2328`,\"--shiki-dark\":`#D4D4D4`},children:`}`})}),`\n`]})})}),`\n`,_jsx(Note,{children:_jsxs(_components.p,{children:[`Setting the storage destination is an organization-level action restricted to\n`,_jsx(_components.strong,{children:`Admin`}),` and `,_jsx(_components.strong,{children:`Owner`}),` members, and one provider is active at a time. Saving a\nGCS configuration replaces whatever provider was configured before.`]})}),`\n`,_jsx(Heading,{level:`3`,id:`field-constraints`,children:`Field constraints`}),`\n`,_jsxs(Table,{children:[_jsx(_components.thead,{children:_jsxs(_components.tr,{children:[_jsx(_components.th,{children:`Field`}),_jsx(_components.th,{children:`Limit enforced on save`}),_jsx(_components.th,{children:`Also validated at job time`})]})}),_jsxs(_components.tbody,{children:[_jsxs(_components.tr,{children:[_jsx(_components.td,{children:_jsx(_components.code,{children:`bucket`})}),_jsx(_components.td,{children:`255 characters`}),_jsx(_components.td,{children:`3 to 63 characters, lowercase letters, digits, hyphens and periods, no consecutive periods, not an IP address`})]}),_jsxs(_components.tr,{children:[_jsx(_components.td,{children:_jsx(_components.code,{children:`project_id`})}),_jsx(_components.td,{children:`128 characters`}),_jsx(_components.td,{children:`Non-empty; a non-standard shape is warned about but accepted`})]}),_jsxs(_components.tr,{children:[_jsx(_components.td,{children:_jsx(_components.code,{children:`credentials_json`})}),_jsx(_components.td,{children:`10 000 characters`}),_jsxs(_components.td,{children:[`Must parse as JSON, `,_jsx(_components.code,{children:`type`}),` must be `,_jsx(_components.code,{children:`service_account`}),`, must contain `,_jsx(_components.code,{children:`project_id`}),`, `,_jsx(_components.code,{children:`private_key`}),` and `,_jsx(_components.code,{children:`client_email`})]})]})]})]}),`\n`,_jsx(_components.p,{children:`A normal service account key is well under 3 KB, so the 10 000 character limit only bites if you paste something that is not a single key file.`}),`\n`,_jsx(Heading,{level:`2`,id:`verify-it-for-real`,children:`Verify it for real`}),`\n`,_jsx(_components.p,{children:`Because the connection test never contacts Google, the first honest signal is a delivered file. Run one small job and look in the bucket.`}),`\n`,_jsxs(Steps,{children:[_jsx(Step,{title:`Submit a short download`,children:_jsx(CodeBlock,{filename:``,numberOfLines:`7`,language:`shellscript`,children:_jsx(_components.pre,{className:`shiki shiki-themes github-light-default dark-plus`,style:{backgroundColor:`#ffffff`,\"--shiki-dark-bg\":`#0B0C0E`,color:`#1f2328`,\"--shiki-dark\":`#D4D4D4`},language:`shellscript`,children:_jsxs(_components.code,{language:`shellscript`,numberOfLines:`7`,children:[_jsxs(_components.span,{className:`line`,children:[_jsx(_components.span,{style:{color:`#953800`,\"--shiki-dark\":`#DCDCAA`},children:`curl`}),_jsx(_components.span,{style:{color:`#0550AE`,\"--shiki-dark\":`#569CD6`},children:` -X`}),_jsx(_components.span,{style:{color:`#0A3069`,\"--shiki-dark\":`#CE9178`},children:` POST`}),_jsx(_components.span,{style:{color:`#0A3069`,\"--shiki-dark\":`#CE9178`},children:` \"https://api.tornadoapi.io/jobs\"`}),_jsx(_components.span,{style:{color:`#CF222E`,\"--shiki-dark\":`#D7BA7D`},children:` \\\\`})]}),`\n`,_jsxs(_components.span,{className:`line`,children:[_jsx(_components.span,{style:{color:`#0550AE`,\"--shiki-dark\":`#569CD6`},children:` -H`}),_jsx(_components.span,{style:{color:`#0A3069`,\"--shiki-dark\":`#CE9178`},children:` \"x-api-key: sk_your_api_key\"`}),_jsx(_components.span,{style:{color:`#CF222E`,\"--shiki-dark\":`#D7BA7D`},children:` \\\\`})]}),`\n`,_jsxs(_components.span,{className:`line`,children:[_jsx(_components.span,{style:{color:`#0550AE`,\"--shiki-dark\":`#569CD6`},children:` -H`}),_jsx(_components.span,{style:{color:`#0A3069`,\"--shiki-dark\":`#CE9178`},children:` \"Content-Type: application/json\"`}),_jsx(_components.span,{style:{color:`#CF222E`,\"--shiki-dark\":`#D7BA7D`},children:` \\\\`})]}),`\n`,_jsxs(_components.span,{className:`line`,children:[_jsx(_components.span,{style:{color:`#0550AE`,\"--shiki-dark\":`#569CD6`},children:` -d`}),_jsx(_components.span,{style:{color:`#0A3069`,\"--shiki-dark\":`#CE9178`},children:` '{`})]}),`\n`,_jsx(_components.span,{className:`line`,children:_jsx(_components.span,{style:{color:`#0A3069`,\"--shiki-dark\":`#CE9178`},children:` \"url\": \"https://www.youtube.com/watch?v=dQw4w9WgXcQ\",`})}),`\n`,_jsx(_components.span,{className:`line`,children:_jsx(_components.span,{style:{color:`#0A3069`,\"--shiki-dark\":`#CE9178`},children:` \"max_resolution\": \"360\"`})}),`\n`,_jsx(_components.span,{className:`line`,children:_jsx(_components.span,{style:{color:`#0A3069`,\"--shiki-dark\":`#CE9178`},children:` }'`})}),`\n`]})})})}),_jsxs(Step,{title:`Wait for the job to finish, then list the bucket`,children:[_jsx(CodeBlock,{filename:``,numberOfLines:`1`,language:`shellscript`,children:_jsx(_components.pre,{className:`shiki shiki-themes github-light-default dark-plus`,style:{backgroundColor:`#ffffff`,\"--shiki-dark-bg\":`#0B0C0E`,color:`#1f2328`,\"--shiki-dark\":`#D4D4D4`},language:`shellscript`,children:_jsxs(_components.code,{language:`shellscript`,numberOfLines:`1`,children:[_jsxs(_components.span,{className:`line`,children:[_jsx(_components.span,{style:{color:`#953800`,\"--shiki-dark\":`#DCDCAA`},children:`gcloud`}),_jsx(_components.span,{style:{color:`#0A3069`,\"--shiki-dark\":`#CE9178`},children:` storage`}),_jsx(_components.span,{style:{color:`#0A3069`,\"--shiki-dark\":`#CE9178`},children:` ls`}),_jsx(_components.span,{style:{color:`#0550AE`,\"--shiki-dark\":`#569CD6`},children:` -r`}),_jsx(_components.span,{style:{color:`#0A3069`,\"--shiki-dark\":`#CE9178`},children:` gs://tornado-downloads/videos/`})]}),`\n`]})})}),_jsxs(_components.p,{children:[`Delivered files land under the base folder, which defaults to `,_jsx(_components.code,{children:`videos/`}),`.`]})]}),_jsx(Step,{title:`Then test a large file`,children:_jsx(_components.p,{children:`A small file may use only the simple-upload path. Run a second job\nthat produces at least 100 MiB (a longer video at a higher resolution) and\nverify the large-file mode used by your deployment. Successful simple upload\ndoes not prove composite or XML multipart permissions.`})}),_jsxs(Step,{title:`Then test a read`,children:[_jsx(_components.p,{children:`Fetch the delivered object with the service account’s own credentials, which\nis the same authorisation a signed download URL uses:`}),_jsx(CodeBlock,{filename:``,numberOfLines:`2`,language:`shellscript`,children:_jsx(_components.pre,{className:`shiki shiki-themes github-light-default dark-plus`,style:{backgroundColor:`#ffffff`,\"--shiki-dark-bg\":`#0B0C0E`,color:`#1f2328`,\"--shiki-dark\":`#D4D4D4`},language:`shellscript`,children:_jsxs(_components.code,{language:`shellscript`,numberOfLines:`2`,children:[_jsxs(_components.span,{className:`line`,children:[_jsx(_components.span,{style:{color:`#953800`,\"--shiki-dark\":`#DCDCAA`},children:`gcloud`}),_jsx(_components.span,{style:{color:`#0A3069`,\"--shiki-dark\":`#CE9178`},children:` auth`}),_jsx(_components.span,{style:{color:`#0A3069`,\"--shiki-dark\":`#CE9178`},children:` activate-service-account`}),_jsx(_components.span,{style:{color:`#0550AE`,\"--shiki-dark\":`#569CD6`},children:` --key-file=tornado-delivery.json`})]}),`\n`,_jsxs(_components.span,{className:`line`,children:[_jsx(_components.span,{style:{color:`#953800`,\"--shiki-dark\":`#DCDCAA`},children:`gcloud`}),_jsx(_components.span,{style:{color:`#0A3069`,\"--shiki-dark\":`#CE9178`},children:` storage`}),_jsx(_components.span,{style:{color:`#0A3069`,\"--shiki-dark\":`#CE9178`},children:` cp`}),_jsx(_components.span,{style:{color:`#0A3069`,\"--shiki-dark\":`#CE9178`},children:` gs://tornado-downloads/videos/`}),_jsx(_components.span,{style:{color:`#CF222E`,\"--shiki-dark\":`#D4D4D4`},children:`\u003c`}),_jsx(_components.span,{style:{color:`#0A3069`,\"--shiki-dark\":`#CE9178`},children:`delivered-fil`}),_jsx(_components.span,{style:{color:`#0A3069`,\"--shiki-dark\":`#CE9178`},children:`e`}),_jsx(_components.span,{style:{color:`#CF222E`,\"--shiki-dark\":`#D4D4D4`},children:`>`}),_jsx(_components.span,{style:{color:`#0A3069`,\"--shiki-dark\":`#CE9178`},children:` /tmp/check.mp4`})]}),`\n`]})})}),_jsxs(_components.p,{children:[`A 403 here can indicate missing `,_jsx(_components.code,{children:`storage.objects.get`}),` or another access-policy restriction. Also open the actual delivery link from the recipient’s network; a successful command-line read alone does not prove recipient access.`]})]})]}),`\n`,_jsxs(Note,{children:[_jsxs(_components.p,{children:[_jsx(_components.strong,{children:`Verify both the object and its delivered URL.`}),` The worker uses the actual\nGCS backend to create the signed URL, stores it with the completed job, and\nincludes it as `,_jsx(_components.code,{children:`s3_url`}),` in the per-job completion webhook. Job polling can\nreturn that recorded URL while it is fresh. The legacy field name does not\nimply an S3 destination. A missing or expired link is not proof that upload\nfailed: also check the object in your bucket.`]}),_jsxs(_components.p,{children:[`Organization-level custom webhooks sent by the dashboard are a separate path:\nthat dispatcher currently presigns S3/Azure only, so its `,_jsx(_components.code,{children:`download_url`}),` is not\navailable for GCS.`]})]}),`\n`,_jsx(Note,{children:_jsxs(_components.p,{children:[_jsx(_components.strong,{children:`If nothing arrives at all, check which configuration the job used before you\nsuspect IAM.`}),` The job pipeline resolves storage per API key, from the\nconfiguration set through `,_jsx(_components.a,{href:`/features/custom-storage#google-cloud-storage`,children:_jsx(_components.code,{children:`POST /user/gcs`})}),`\nwith that key’s `,_jsx(_components.code,{children:`x-api-key`}),`, falling back to the organization default and then\nto Tornado’s managed storage. A job that ran against managed storage still\nreports `,_jsx(_components.code,{children:`Completed`}),`, with the file in Tornado’s bucket rather than yours. See\n`,_jsx(_components.a,{href:`/features/custom-storage#job-completed-but-the-file-isnt-in-my-storage`,children:`Custom Storage`}),`.`]})}),`\n`,_jsx(Heading,{level:`2`,id:`troubleshooting`,children:`Troubleshooting`}),`\n`,_jsx(_components.p,{children:`The dashboard’s connection test cannot produce any of these errors, because it makes no request. They surface on the first job instead.`}),`\n`,_jsxs(_components.p,{children:[`The worker treats `,_jsx(_components.code,{children:`401`}),`, `,_jsx(_components.code,{children:`403`}),`, `,_jsx(_components.code,{children:`404`}),`, `,_jsx(_components.code,{children:`invalid_grant`}),` and `,_jsx(_components.code,{children:`Invalid JWT`}),` as permanent and fails the job immediately without retrying. When Google’s response identifies the cause, the job’s `,_jsx(_components.code,{children:`error`}),` says exactly what to fix, and `,_jsx(_components.code,{children:`failure_kind`}),` classifies it (the job status is `,_jsx(_components.code,{children:`failed`}),`):`]}),`\n`,_jsxs(Table,{children:[_jsx(_components.thead,{children:_jsxs(_components.tr,{children:[_jsx(_components.th,{children:_jsx(_components.code,{children:`failure_kind`})}),_jsx(_components.th,{children:`Cause`}),_jsx(_components.th,{children:`What the error message gives you`})]})}),_jsxs(_components.tbody,{children:[_jsxs(_components.tr,{children:[_jsx(_components.td,{children:_jsx(_components.code,{children:`destination_permission`})}),_jsx(_components.td,{children:`The service account lacks a permission on the bucket`}),_jsxs(_components.td,{children:[`The service account, the missing permission (for example `,_jsx(_components.code,{children:`storage.objects.create`}),`, or `,_jsx(_components.code,{children:`storage.objects.delete`}),` when an object name is overwritten) and the narrowest predefined role that grants it`]})]}),_jsxs(_components.tr,{children:[_jsx(_components.td,{children:_jsx(_components.code,{children:`destination_not_found`})}),_jsx(_components.td,{children:`The bucket does not exist, or the service account cannot see it`}),_jsx(_components.td,{children:`The bucket name and service account to check`})]}),_jsxs(_components.tr,{children:[_jsx(_components.td,{children:_jsx(_components.code,{children:`destination_credentials`})}),_jsx(_components.td,{children:`Google rejected the key: deleted, rotated, disabled or malformed`}),_jsxs(_components.td,{children:[`Google’s reason, for example `,_jsx(_components.code,{children:`Invalid JWT Signature.`})]})]}),_jsxs(_components.tr,{children:[_jsx(_components.td,{children:_jsx(_components.code,{children:`destination_billing`})}),_jsx(_components.td,{children:`The project that owns the bucket cannot accept uploads, for example billing disabled`}),_jsx(_components.td,{children:`Google’s message`})]})]})]}),`\n`,_jsxs(_components.p,{children:[`Other permanent refusals keep the generic message `,_jsx(_components.em,{children:`“Invalid GCS storage credentials. Please check your service_account_json, project_id, and bucket name.”`}),`, which covers several distinct causes.`]}),`\n`,_jsxs(Table,{children:[_jsx(_components.thead,{children:_jsxs(_components.tr,{children:[_jsx(_components.th,{children:`Symptom`}),_jsx(_components.th,{children:`Cause`}),_jsx(_components.th,{children:`Fix`})]})}),_jsxs(_components.tbody,{children:[_jsxs(_components.tr,{children:[_jsx(_components.td,{children:`Simple uploads deliver, composite uploads fail`}),_jsxs(_components.td,{children:[`One possible cause is missing `,_jsx(_components.code,{children:`storage.objects.get`}),` for `,_jsx(_components.code,{children:`objects.compose`}),`. XML multipart has different operation requirements.`]}),_jsx(_components.td,{children:`Inspect the failing operation and grant the permissions for the upload mode in use.`})]}),_jsxs(_components.tr,{children:[_jsx(_components.td,{children:`Files deliver, but download links return 403`}),_jsxs(_components.td,{children:[`The role lacks `,_jsx(_components.code,{children:`storage.objects.get`}),`. Signing succeeds offline; the reader’s GET is what gets rejected.`]}),_jsxs(_components.td,{children:[`Grant `,_jsx(_components.code,{children:`roles/storage.objectUser`}),` on the bucket.`]})]}),_jsxs(_components.tr,{children:[_jsx(_components.td,{children:`Re-running a job that writes an object name that already exists fails`}),_jsxs(_components.td,{children:[`Overwriting an existing object requires `,_jsx(_components.code,{children:`storage.objects.delete`}),` in addition to `,_jsx(_components.code,{children:`storage.objects.create`}),`.`]}),_jsxs(_components.td,{children:[`Grant `,_jsx(_components.code,{children:`roles/storage.objectUser`}),` on the bucket.`]})]}),_jsxs(_components.tr,{children:[_jsxs(_components.td,{children:[_jsx(_components.code,{children:`verify_credentials.txt`}),` or `,_jsx(_components.code,{children:`*.tornado.tmp.*`}),` objects accumulate in the bucket`]}),_jsx(_components.td,{children:`Cleanup can fail because of missing delete permission, retention rules or an interrupted upload.`}),_jsx(_components.td,{children:`Check the failure and retention policy. Remove only confirmed abandoned Tornado objects after their uploads have stopped; a matching name alone does not prove an object is unused.`})]}),_jsxs(_components.tr,{children:[_jsx(_components.td,{children:`Every job fails immediately, nothing appears in the bucket`}),_jsx(_components.td,{children:`The service account may lack effective access to this bucket, or the key may be invalid. A project grant can be inherited by its buckets; a grant on an unrelated project does not authorize this one.`}),_jsx(_components.td,{children:`Check the service-account identity, key and effective bucket IAM, including inherited policies.`})]}),_jsxs(_components.tr,{children:[_jsx(_components.td,{children:`Every job fails immediately, and the bucket exists`}),_jsx(_components.td,{children:`The bucket name or service-account permissions may be wrong. The service account and bucket can belong to different projects when IAM grants access.`}),_jsx(_components.td,{children:`Confirm the bucket name and the service account’s effective permissions; do not require matching project ownership as a substitute for IAM.`})]}),_jsxs(_components.tr,{children:[_jsx(_components.td,{children:`Configuration saved green, every job fails on credentials`}),_jsxs(_components.td,{children:[`The pasted JSON is not a complete, valid key file: a partial paste, a base64 blob, or `,_jsx(_components.code,{children:`private_key`}),` newlines mangled in transit. The dashboard test does not parse the JSON, so this is accepted at save time.`]}),_jsx(_components.td,{children:`Re-copy the whole file, opening brace to closing brace, into the field.`})]}),_jsxs(_components.tr,{children:[_jsx(_components.td,{children:`Jobs that used to work start failing on credentials`}),_jsx(_components.td,{children:`The key was disabled or deleted in the console, or the service account itself was deleted or disabled.`}),_jsx(_components.td,{children:`Create a new key and update the configuration. Note that deleting a key does not revoke short-lived tokens already issued from it, so a revoked key can appear to keep working briefly.`})]}),_jsxs(_components.tr,{children:[_jsx(_components.td,{children:_jsx(_components.code,{children:`Service account JSON 'type' must be 'service_account'`})}),_jsxs(_components.td,{children:[`An OAuth client secret, an external account configuration, or an `,_jsx(_components.code,{children:`authorized_user`}),` credential was pasted instead of a service account key.`]}),_jsxs(_components.td,{children:[`Download the key from the service account’s `,_jsx(_components.strong,{children:`Keys`}),` tab, not from `,_jsx(_components.strong,{children:`APIs \u0026 Services › Credentials`}),`.`]})]}),_jsxs(_components.tr,{children:[_jsx(_components.td,{children:`Key creation is refused in the console`}),_jsxs(_components.td,{children:[_jsx(_components.code,{children:`constraints/iam.disableServiceAccountKeyCreation`}),` is enforced on the project, or another organization policy blocks key creation.`]}),_jsx(_components.td,{children:`Ask an organization administrator to grant an exception for the project.`})]})]})]}),`\n`,_jsx(Heading,{level:`2`,id:`rotating-the-key`,children:`Rotating the key`}),`\n`,_jsx(_components.p,{children:`A service account can hold more than one key at a time, so overlap them and you rotate without an outage.`}),`\n`,_jsxs(Steps,{children:[_jsx(Step,{title:`Create a second key`,children:_jsx(_components.p,{children:`Add a new JSON key on the same service account. Both keys are now valid.`})}),_jsx(Step,{title:`Update Tornado and confirm`,children:_jsx(_components.p,{children:`Paste the new key into the dashboard and run a test job through to the\nbucket.`})}),_jsx(Step,{title:`Disable, then delete, the old key`,children:_jsx(_components.p,{children:`Disable the old key first and watch for failures before deleting it.\nDeletion is permanent.`})})]}),`\n`,_jsx(Note,{children:_jsx(_components.p,{children:`Rotation also invalidates links you have already handed out. Google’s\ndocumentation is explicit that a signed URL grants access “until the\nexpiration time for the URL is reached or the key used to sign the URL is\nrotated”, so retiring a key cuts short every outstanding link signed with it,\nahead of its 24 hour expiry. Rotate when those links are expendable, or expect\nrecipients to need fresh ones.`})}),`\n`,_jsx(Heading,{level:`2`,id:`not-available-on-this-route`,children:`Not available on this route`}),`\n`,_jsxs(Table,{children:[_jsx(_components.thead,{children:_jsxs(_components.tr,{children:[_jsx(_components.th,{children:`Feature`}),_jsx(_components.th,{children:`Status`})]})}),_jsxs(_components.tbody,{children:[_jsxs(_components.tr,{children:[_jsx(_components.td,{children:`Live credential validation from the dashboard`}),_jsxs(_components.td,{children:[`Not implemented. `,_jsx(_components.code,{children:`POST /settings/storage/test`}),` returns `,_jsx(_components.code,{children:`verified: false`}),` for GCS and makes no network call. The per-API-key endpoint `,_jsx(_components.a,{href:`/features/custom-storage#google-cloud-storage`,children:_jsx(_components.code,{children:`POST /user/gcs`})}),` does validate for real, by uploading and deleting a `,_jsx(_components.code,{children:`verify_credentials.txt`}),` probe object.`]})]}),_jsxs(_components.tr,{children:[_jsx(_components.td,{children:`Reading the configuration back`}),_jsxs(_components.td,{children:[`Only through `,_jsx(_components.code,{children:`GET /settings/storage`}),`, with `,_jsx(_components.code,{children:`credentials_json`}),` masked. There is no `,_jsx(_components.code,{children:`GET /user/gcs`}),`.`]})]}),_jsxs(_components.tr,{children:[_jsxs(_components.td,{children:[_jsx(_components.code,{children:`folder_prefix`}),` / `,_jsx(_components.code,{children:`base_folder`})]}),_jsxs(_components.td,{children:[`Not accepted. Available on `,_jsx(_components.a,{href:`/features/custom-storage#folder-prefix`,children:_jsx(_components.code,{children:`POST /user/gcs`})}),` only. Deliveries through the dashboard route use the default `,_jsx(_components.code,{children:`videos/`}),` base folder.`]})]}),_jsxs(_components.tr,{children:[_jsx(_components.td,{children:`Base64-encoded credentials`}),_jsx(_components.td,{children:`Not accepted. The value is parsed directly as JSON.`})]}),_jsxs(_components.tr,{children:[_jsx(_components.td,{children:`Workload Identity Federation, attached service accounts, impersonation`}),_jsx(_components.td,{children:`Not supported. A downloaded service account key is the only credential type.`})]}),_jsxs(_components.tr,{children:[_jsx(_components.td,{children:`HMAC keys / selecting XML from the dashboard`}),_jsx(_components.td,{children:`No HMAC key fields or customer-facing XML toggle. Workers can optionally use XML multipart with the same service-account credential; the default file path uses the JSON API.`})]}),_jsxs(_components.tr,{children:[_jsx(_components.td,{children:`Customer-supplied encryption keys (CSEK)`}),_jsx(_components.td,{children:`Not supported. Customer-managed keys (CMEK) configured as the bucket default also require the appropriate Google-side encryption-key permissions; verify with a real delivery.`})]}),_jsxs(_components.tr,{children:[_jsx(_components.td,{children:`Automatic bucket creation`}),_jsx(_components.td,{children:`Not supported. Create the bucket first.`})]})]})]})]})}function MDXContent(props={}){const{wrapper:MDXLayout}={..._provideComponents(),...props.components};return MDXLayout?_jsx(MDXLayout,{...props,children:_jsx(_createMdxContent,{...props})}):_createMdxContent(props)}return{default:MDXContent};function _missingMdxReference(id,component){throw new Error(`Expected `+(component?`component`:`object`)+\" `\"+id+\"` to be defined: you likely forgot to import, pass, or provide it.\")}","frontmatter":{},"scope":{"config":{},"pageMetadata":{"title":"Google Cloud Storage","description":"Configure a GCS bucket as your Tornado delivery destination: bucket, service account, bucket-scoped IAM role, and how to verify it for real","href":"/storage/google-cloud-storage"}}},"mdxSourceWithNoJs":null},"mdxExtracts":{"tableOfContents":[{"title":"Overview","slug":"overview","depth":2,"children":[]},{"title":"Current limitations","slug":"current-limitations","depth":2,"children":[]},{"title":"Before you start","slug":"before-you-start","depth":2,"children":[]},{"title":"1. Create the bucket","slug":"1-create-the-bucket","depth":2,"children":[{"title":"Choosing a location","slug":"choosing-a-location","depth":3,"children":[]}]},{"title":"2. Create the service account","slug":"2-create-the-service-account","depth":2,"children":[]},{"title":"3. Grant the role on the bucket","slug":"3-grant-the-role-on-the-bucket","depth":2,"children":[{"title":"What Tornado actually does in your bucket","slug":"what-tornado-actually-does-in-your-bucket","depth":3,"children":[]},{"title":"Minimum","slug":"minimum","depth":3,"children":[]},{"title":"Recommended","slug":"recommended","depth":3,"children":[]},{"title":"Granting it","slug":"granting-it","depth":3,"children":[]}]},{"title":"4. Create and download the JSON key","slug":"4-create-and-download-the-json-key","depth":2,"children":[]},{"title":"5. Enter the three fields","slug":"5-enter-the-three-fields","depth":2,"children":[{"title":"Pasting the key","slug":"pasting-the-key","depth":3,"children":[]},{"title":"Field constraints","slug":"field-constraints","depth":3,"children":[]}]},{"title":"Verify it for real","slug":"verify-it-for-real","depth":2,"children":[]},{"title":"Troubleshooting","slug":"troubleshooting","depth":2,"children":[]},{"title":"Rotating the key","slug":"rotating-the-key","depth":2,"children":[]},{"title":"Not available on this route","slug":"not-available-on-this-route","depth":2,"children":[]}],"codeExamples":{}},"jsLinks":["/_mintlify/custom/velyssoftware/96dc543542618d4c/js/crisp.js"],"subdomain":"velyssoftware","siteName":"Tornado API","title":"Google Cloud Storage - Tornado API","navMaps":{"firstHrefInVersion":{},"firstHrefInLanguage":{}},"lastModified":"2026-10-10T21:59:32.891Z","originalFileLocation":"storage/google-cloud-storage.mdx"},"headHtml":"\u003cscript data-mint-head>(function(){var d=document.documentElement,p=null;if(!p){try{p=localStorage.getItem(\"isDarkMode\")}catch(e){}if(p==='true')p='dark';else if(p==='false')p='light';if(p!=='light'\u0026\u0026p!=='dark'\u0026\u0026p!=='system')p=\"system\"}var r=p==='system'?(matchMedia(\"(prefers-color-scheme: dark)\").matches?'dark':'light'):p;d.classList.remove(r==='dark'?'light':'dark');d.classList.add(r);d.setAttribute(\"data-theme-preference\",p)})();\u003c/script>\u003cmeta data-mint-head name=\"description\" content=\"Configure a GCS bucket as your Tornado delivery destination: bucket, service account, bucket-scoped IAM role, and how to verify it for real\">\u003cmeta data-mint-head name=\"application-name\" content=\"Tornado API\">\u003cmeta data-mint-head name=\"generator\" content=\"Mintlify\">\u003clink data-mint-head rel=\"icon\" type=\"image/png\" sizes=\"192x192\" href=\"/mintlify-assets/_mintlify/favicons/velyssoftware/ZYu7gvp-FLl2IouP/_generated/favicon/android-chrome-192x192.png\" media=\"(prefers-color-scheme: light)\">\u003clink data-mint-head rel=\"apple-touch-icon\" type=\"image/png\" sizes=\"180x180\" href=\"/mintlify-assets/_mintlify/favicons/velyssoftware/ZYu7gvp-FLl2IouP/_generated/favicon/apple-touch-icon.png\">\u003clink data-mint-head rel=\"icon\" type=\"image/png\" sizes=\"16x16\" href=\"/mintlify-assets/_mintlify/favicons/velyssoftware/ZYu7gvp-FLl2IouP/_generated/favicon/favicon-16x16.png\" media=\"(prefers-color-scheme: light)\">\u003clink data-mint-head rel=\"icon\" type=\"image/png\" sizes=\"32x32\" href=\"/mintlify-assets/_mintlify/favicons/velyssoftware/ZYu7gvp-FLl2IouP/_generated/favicon/favicon-32x32.png\" media=\"(prefers-color-scheme: light)\">\u003clink data-mint-head rel=\"shortcut icon\" type=\"image/x-icon\" href=\"/mintlify-assets/_mintlify/favicons/velyssoftware/ZYu7gvp-FLl2IouP/_generated/favicon/favicon.ico\" media=\"(prefers-color-scheme: light)\">\u003clink data-mint-head rel=\"icon\" type=\"image/png\" sizes=\"192x192\" href=\"/mintlify-assets/_mintlify/favicons/velyssoftware/ZYu7gvp-FLl2IouP/_generated/favicon-dark/android-chrome-192x192.png\" media=\"(prefers-color-scheme: dark)\">\u003clink data-mint-head rel=\"icon\" type=\"image/png\" sizes=\"16x16\" href=\"/mintlify-assets/_mintlify/favicons/velyssoftware/ZYu7gvp-FLl2IouP/_generated/favicon-dark/favicon-16x16.png\" media=\"(prefers-color-scheme: dark)\">\u003clink data-mint-head rel=\"icon\" type=\"image/png\" sizes=\"32x32\" href=\"/mintlify-assets/_mintlify/favicons/velyssoftware/ZYu7gvp-FLl2IouP/_generated/favicon-dark/favicon-32x32.png\" media=\"(prefers-color-scheme: dark)\">\u003clink data-mint-head rel=\"shortcut icon\" type=\"image/x-icon\" href=\"/mintlify-assets/_mintlify/favicons/velyssoftware/ZYu7gvp-FLl2IouP/_generated/favicon-dark/favicon.ico\" media=\"(prefers-color-scheme: dark)\">\u003cmeta data-mint-head property=\"og:title\" content=\"Google Cloud Storage - Tornado API\">\u003cmeta data-mint-head property=\"og:description\" content=\"Configure a GCS bucket as your Tornado delivery destination: bucket, service account, bucket-scoped IAM role, and how to verify it for real\">\u003cmeta data-mint-head property=\"og:site_name\" content=\"Tornado API\">\u003cmeta data-mint-head property=\"og:url\" content=\"https://docs.tornadoapi.io/storage/google-cloud-storage\">\u003cmeta data-mint-head property=\"og:type\" content=\"website\">\u003cmeta data-mint-head property=\"og:image\" content=\"https://velyssoftware.mintlify.app/_mintlify/api/og/v2?division=Documentation\u0026amp;title=Google+Cloud+Storage\u0026amp;description=Configure+a+GCS+bucket+as+your+Tornado+delivery+destination%3A+bucket%2C+service+account%2C+bucket-scoped+IAM+role%2C+and+how+to+verify+it+for+real\u0026amp;theme=02027aa6c7212144357d4453\u0026amp;signature=b8d10b25e6c36b96481d74d8083b3ca9e39fc1dc76a136c31132f63ad62f99c2\">\u003cmeta data-mint-head property=\"og:image:width\" content=\"1200\">\u003cmeta data-mint-head property=\"og:image:height\" content=\"630\">\u003cmeta data-mint-head name=\"twitter:card\" content=\"summary_large_image\">\u003cmeta data-mint-head name=\"twitter:title\" content=\"Google Cloud Storage - Tornado API\">\u003cmeta data-mint-head name=\"twitter:description\" content=\"Configure a GCS bucket as your Tornado delivery destination: bucket, service account, bucket-scoped IAM role, and how to verify it for real\">\u003cmeta data-mint-head name=\"twitter:image\" content=\"https://velyssoftware.mintlify.app/_mintlify/api/og/v2?division=Documentation\u0026amp;title=Google+Cloud+Storage\u0026amp;description=Configure+a+GCS+bucket+as+your+Tornado+delivery+destination%3A+bucket%2C+service+account%2C+bucket-scoped+IAM+role%2C+and+how+to+verify+it+for+real\u0026amp;theme=02027aa6c7212144357d4453\u0026amp;signature=b8d10b25e6c36b96481d74d8083b3ca9e39fc1dc76a136c31132f63ad62f99c2\">\u003cmeta data-mint-head name=\"twitter:image:width\" content=\"1200\">\u003cmeta data-mint-head name=\"twitter:image:height\" content=\"630\">\u003cmeta data-mint-head name=\"msapplication-config\" content=\"/mintlify-assets/_mintlify/favicons/velyssoftware/ZYu7gvp-FLl2IouP/_generated/favicon/browserconfig.xml\">\u003cmeta data-mint-head name=\"apple-mobile-web-app-title\" content=\"Tornado API\">\u003cmeta data-mint-head name=\"msapplication-TileColor\" content=\"#2563EB\">\u003clink data-mint-head rel=\"canonical\" href=\"https://docs.tornadoapi.io/storage/google-cloud-storage\">\u003clink data-mint-head rel=\"alternate\" type=\"application/xml\" href=\"/sitemap.xml\">\u003clink data-mint-head rel=\"alternate\" type=\"text/markdown\" href=\"/storage/google-cloud-storage.md\">\u003cscript data-mint-head type=\"application/ld+json\">{\"@context\":\"https://schema.org\",\"@type\":\"WebSite\",\"name\":\"Tornado API\",\"creator\":{\"@type\":\"Organization\",\"name\":\"Mintlify\",\"url\":\"https://mintlify.com\"}}\u003c/script>","siteName":"Tornado API","nav":{"global":null,"tabs":[{"tab":"API Reference","groups":[{"group":"API Reference","pages":[{"title":"API Reference","href":"/api-reference/introduction"},{"title":"Billing \u0026 Metering","href":"/api-reference/billing"}]},{"group":"Jobs","pages":[{"title":"Create Job","api":"POST /jobs","href":"/api-reference/jobs/create"},{"title":"Get Job Status","api":"GET /jobs/{id}","href":"/api-reference/jobs/get-status"},{"title":"List Jobs","api":"GET /jobs","href":"/api-reference/jobs/list"},{"title":"Cancel Job","api":"DELETE /jobs/{id}","href":"/api-reference/jobs/cancel"},{"title":"Retry Job","api":"POST /jobs/{id}/retry","href":"/api-reference/jobs/retry"},{"title":"Delete Job File","api":"DELETE /jobs/{id}/file","href":"/api-reference/jobs/delete-file"}]},{"group":"Bulk","pages":[{"title":"Bulk Create Jobs","api":"POST /jobs/bulk","href":"/api-reference/bulk/create"}]},{"group":"Metadata","pages":[{"title":"Get Metadata","api":"POST /metadata","href":"/api-reference/metadata/get"},{"title":"Is Short","api":"POST /is-short","href":"/api-reference/metadata/is-short"},{"title":"Spotify Show Has Video","api":"GET /spotify/show/{id}/hasVideo","href":"/api-reference/metadata/spotify-has-video"}]},{"group":"Batch Operations","pages":[{"title":"Get Batch Status","api":"GET /batch/{id}","href":"/api-reference/batch/get-status"},{"title":"Rename Batch Jobs","api":"PATCH /batch/{id}/jobs","href":"/api-reference/batch/rename-jobs"},{"title":"Start Batch","api":"POST /batch/{id}/start","href":"/api-reference/batch/start"}]},{"group":"User","pages":[{"title":"Get Usage","api":"GET /usage","href":"/api-reference/user/get-usage"},{"title":"Configure S3 Storage","api":"POST /user/s3","href":"/api-reference/user/configure-s3"},{"title":"Get S3 Storage","api":"GET /user/s3","href":"/api-reference/user/get-s3"},{"title":"Remove S3 Storage","api":"DELETE /user/s3","href":"/api-reference/user/delete-s3"},{"title":"Configure Azure Blob Storage","api":"POST /user/blob","href":"/api-reference/user/configure-blob"},{"title":"Remove Azure Blob Storage","api":"DELETE /user/blob","href":"/api-reference/user/delete-blob"},{"title":"Configure Google Cloud Storage","api":"POST /user/gcs","href":"/api-reference/user/configure-gcs"},{"title":"Remove Google Cloud Storage","api":"DELETE /user/gcs","href":"/api-reference/user/delete-gcs"},{"title":"Configure Google Drive","api":"POST /user/gdrive","href":"/api-reference/user/configure-gdrive"},{"title":"Remove Google Drive","api":"DELETE /user/gdrive","href":"/api-reference/user/delete-gdrive"},{"title":"Configure Alibaba OSS","api":"POST /user/oss","href":"/api-reference/user/configure-oss"},{"title":"Remove Alibaba OSS","api":"DELETE /user/oss","href":"/api-reference/user/delete-oss"},{"title":"Update Bucket Config (Legacy)","api":"POST /user/bucket","href":"/api-reference/user/update-bucket"},{"title":"Reset Bucket Config (Legacy)","api":"DELETE /user/bucket","href":"/api-reference/user/reset-bucket"},{"title":"Configure Slack Webhook","api":"POST /user/slack","href":"/api-reference/user/configure-slack"},{"title":"Delete Slack Webhook","api":"DELETE /user/slack","href":"/api-reference/user/delete-slack"}]}]}]},"title":"Google Cloud Storage - Tornado API","buildId":"dpl_2xiSHD9XEvLsPGPcfoMtkLTB3dy9","deploymentId":"dpl_2xiSHD9XEvLsPGPcfoMtkLTB3dy9","dynamics":[],"chunks":[]}