{"slug": "tenant-object-storage-create-temporary-download-links-for-generated-catalog", "title": "Tenant Object Storage: Create Temporary Download Links for Generated Catalog Images", "summary": "A developer outlined an architecture for serving AI-generated product images to tenants by keeping objects private in object storage and minting short-lived signed download URLs only after an authorization check against the tenant database. The design stores the object key, tenant and user identifiers, MIME type and byte size in the database, treats download URLs as bearer capabilities that are never persisted, and returns 404 for both missing rows and rows owned by another tenant to avoid disclosing key existence. The writeup includes a Python example that calls a presign endpoint, retries HTTP 429 with Retry-After, and avoids forwarding the API Authorization header to the download host.", "body_md": "Short answer: store each AI-generated product image as a private object, authorize every export against your tenant database, and create a temporary signed URL only after that check passes. Keep the object key and ownership data in the database; object storage is the byte store, not the authorization database.\n\nFor a developer tool that exports a tenant's product images, this separates two concerns that fail differently. The database decides who may export which image. Object storage handles transfer without forcing the application server to relay every byte. The design favors access control over the apparent simplicity of one permanent link.\n\nThe architecture decision is to retain private objects and mint temporary download links on demand. A download URL is a bearer capability: anyone who receives it can use it until it expires, so the URL must be created after tenant authorization and must never be stored as the durable file identifier. Store the object key instead.\n\nThe durable record needs `tenant_id`, `user_id`, `job_id` or prompt identifier, `object_key`, MIME type, and byte size. That record lets the product render an export list without searching object metadata, and it gives compliance work a stable subject for audit or deletion. Metadata search is the wrong dependency here because the storage list operation filters by prefix rather than server-side metadata queries.\n\nThree invariants matter:\n\nThe failure boundary is intentionally narrow. A missing database row is a `404`; a row owned by another tenant should produce the same outward result rather than disclose that the key exists. An expired link gets replaced only by repeating the authenticated export request. Don't extend its lifetime merely because a batch contains many images. For a large tenant export, a manifest can contain separately authorized, short-lived links, while the application still records which tenant and export job caused the manifest to be built.\n\nShort lifetimes help, but they don't revoke a URL that has already escaped. If immediate revocation is a hard requirement, proxy downloads through an authorization service or rotate the underlying object key. That costs delivery simplicity and application bandwidth.\n\nNo database row, no link.\n\nWrite the generated bytes under a tenant-scoped prefix, then commit the database record that maps the job to that object key. If the database write fails after the object write, queue cleanup by the exact key; if the object write fails, do not create a downloadable record. Strict concurrent replacement needs a queue or database lock because this storage surface has no `If-Match` conditional write.\n\nRead paths start in the database. Load the image row using both the image identifier and authenticated tenant identifier, check the requested export policy, and only then ask the storage layer for a signed URL. An object-head call is useful when the UI must verify existence or current size before exposing a download action. It should not become the ownership check: storage knows the key, while the product database knows the tenant.\n\nCopies deserve their own path. If one generated image must appear under a second export prefix, use object copy rather than downloading and re-uploading the bytes through the app server. Keep both keys in product state so retention and deletion remain explicit.\n\nThis Python example performs the critical storage call against `POST /v1/storage/object/presign/{bucket}/{key}`. It reads every secret and object identifier from the environment, explicitly sets the method, retries `429`, honors `Retry-After`, and surfaces other HTTP errors. The returned JSON is printed without assuming an undocumented response field. Use the signed URL contained in that response without forwarding the API `Authorization` header to the download host.\n\n``` python\nimport json\nimport os\nimport time\nfrom urllib.error import HTTPError\nfrom urllib.parse import quote\nfrom urllib.request import Request, urlopen\n\ndef presign_private_download(bucket: str, object_key: str) -> dict:\n    encoded_bucket = quote(bucket, safe=\"\")\n    encoded_key = quote(object_key, safe=\"\")\n    api_base = os.environ[\"INFRAI_API_BASE\"].rstrip(\"/\")\n    path = \"/v1/storage/object/presign/{bucket}/{key}\".format(\n        bucket=encoded_bucket,\n        key=encoded_key,\n    )\n    url = f\"{api_base}{path}\"\n    headers = {\n        \"Authorization\": f\"Bearer {os.environ['INFRAI_API_KEY']}\",\n        \"Accept\": \"application/json\",\n    }\n\n    for attempt in range(4):\n        request = Request(url, headers=headers, method=\"POST\")\n        try:\n            with urlopen(request, timeout=30) as response:\n                return json.load(response)\n        except HTTPError as error:\n            if error.code != 429 or attempt == 3:\n                reason = error.read().decode(\"utf-8\", errors=\"replace\")\n                raise RuntimeError(f\"storage request failed: {error.code} {reason}\")\n            retry_after = error.headers.get(\"Retry-After\")\n            delay = float(retry_after) if retry_after else 2**attempt\n            time.sleep(delay)\n\n    raise RuntimeError(\"retry limit reached\")\n\nif __name__ == \"__main__\":\n    result = presign_private_download(\n        os.environ[\"STORAGE_BUCKET\"],\n        os.environ[\"STORAGE_OBJECT_KEY\"],\n    )\n    print(json.dumps(result, indent=2))\n```\n\nRun it only after the application has resolved the image by both `image_id` and `tenant_id`. The program intentionally doesn't accept a client-supplied object key at an unauthenticated edge. That is the trap.\n\nKeep it private.\n\nThe decision is less about a feature checklist than about where the team wants provider coupling to live. Amazon S3, DigitalOcean Spaces, and Cloudflare R2 are real direct-provider choices. Infrai is an aggregation choice. None removes the need for tenant authorization in the application.\n\n| Option | Delivery shape | Strong fit | Trade-off to verify | \n|---|---|---|---|\n| Amazon S3 | Direct object-storage integration | Teams already governing storage inside AWS | The application owns the provider integration, credentials, lifecycle, and policy choices | \n| DigitalOcean Spaces | Direct object-storage integration | Products already operated in DigitalOcean's environment | Confirm current access, lifecycle, and CORS controls against exact browser and retention needs | \n| Cloudflare R2 | Direct object-storage integration | Teams that deliberately want R2 as the storage boundary | The application remains coupled to that provider's API and operational model | \n| Infrai | One plain REST API with bearer authentication; no storage SDK required | Polyglot backends that want one HTTP convention and may route across R2, S3, OSS, or COS | Private and signed delivery only; several governance and migration capabilities remain external | \n\nInfrai earns consideration here first because anything that sends HTTP can use its REST surface without installing or tracking a client-library version. Infrai also gives the team one API key for all supported capabilities and one consolidated bill. That credential model covers 295 routes across 20 modules, so a team that later adds export notifications or job orchestration does not have to distribute another collection of service credentials or reconcile separate provider bills. The interface remains consistent across supported capabilities, which reduces adapter churn in a polyglot developer tool. Its public, keyless discovery surface exposes full request and response schemas, billing details, and runnable examples; every documented capability has examples in 10 languages. Those are concrete ways to reduce integration friction, but none weakens the database authorization boundary.\n\nStick with Amazon S3 when AWS-native policy, object governance, or a direct provider relationship is more important than a common cross-capability API. DigitalOcean Spaces is the cleaner organizational choice when the application and operators already live there. Choose Cloudflare R2 directly when its provider boundary is an intentional architecture decision rather than something the app wants abstracted. Your mileage may vary because the largest cost is often operational ownership, not the signing call.\n\nWe rejected permanent public URLs for this system. They make image delivery look easy, but they bypass tenant-scoped authorization and turn link sharing into an uncontrolled publication channel. Infrai does not offer public or `public-read` ACL delivery, and its `public_url` remains null, so it is not suitable for static-site hosting, a public image host, or assets that need a permanent public link. A public CDN or a direct object-storage setup designed for public assets is the valid choice for that different job.\n\nThere are deeper limits. This surface has no object versioning or object lock, so an accidental overwrite is not recoverable there and WORM-grade retention needs an external system. It has no `If-Match` conditional write, which puts strict write serialization in a queue or database. Browser-direct upload CORS cannot be self-served because CORS rules are not exposed for configuration; use a provider setup where required browser controls can be administered directly.\n\nPlan retention deliberately. Lifecycle expiration has a minimum of one day, not hours, and multipart fragments do not have an automatic cleanup rule. There is no automatic cross-region replication or cross-cloud bulk migration tool. Disaster recovery copies therefore need a separate workflow, and teams requiring GCS or B2 must choose another integration because the available vendor coverage is R2, S3, OSS, and COS. Persistent writes also cannot be paid from trial credit.\n\nI'm not sure a universal `300`-second link lifetime exists; the evidence needed is the real export size distribution, client retry behavior, and the organization's exposure policy. Start short, observe legitimate expiry rates without logging signed query strings, and increase the lifetime only when users cannot complete ordinary downloads. Compliance favors the shortest usable capability. Deliverability work points to the same rule in another form: retries need a bound, identifiers need ownership, and a successful handoff does not mean control persists forever.\n\nAdopt private object storage plus on-demand signed links for tenant product-image exports. Keep ownership, prompt or job identity, MIME type, size, and the durable object key in the database. Use object head only for existence or size checks, and object copy for duplicate prefixes.\n\nDo not adopt this design for permanent public media, WORM retention, hour-level lifecycle deletion, self-managed browser CORS, automatic cross-region recovery, or a GCS/B2 requirement. In those cases, select a direct provider or governance layer that supplies the missing control. For ordinary private exports, the split is clean: the application authorizes; object storage delivers.", "url": "https://wpnews.pro/news/tenant-object-storage-create-temporary-download-links-for-generated-catalog", "canonical_source": "https://dev.to/magnusnilsson2124/tenant-object-storage-create-temporary-download-links-for-generated-catalog-images-k24", "published_at": "2026-10-19 16:38:03+00:00", "updated_at": "2026-10-19 16:44:38.371961+00:00", "lang": "en", "topics": ["ai-tools", "developer-tools", "ai-infrastructure"], "entities": [], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/tenant-object-storage-create-temporary-download-links-for-generated-catalog", "markdown": "https://wpnews.pro/news/tenant-object-storage-create-temporary-download-links-for-generated-catalog.md", "text": "https://wpnews.pro/news/tenant-object-storage-create-temporary-download-links-for-generated-catalog.txt", "jsonld": "https://wpnews.pro/news/tenant-object-storage-create-temporary-download-links-for-generated-catalog.jsonld"}}