DEV Community

YannickSterling6563
YannickSterling6563

Posted on

Tenant-Isolated AI Image Storage: Cheap Signed Downloads, No Open Bucket

Short answer: For a logistics AI app, keep generated images in private object storage, authorize the tenant in the application, and issue a short-lived signed download link so the app does not proxy the media bytes.

Keep it private.

That answer is intentionally boring. Boring is useful when a fleet operator uploads a 300 MB inspection video, an image model produces several derivatives, and two customers happen to use the same filename. The storage bill matters, but tenant isolation matters first: a low-cost design that can cross an account boundary is not a low-cost design.

I learned to treat this as a delivery contract, not a bucket setting. The application owns identity and authorization; storage owns the bytes; the signed link is a temporary handoff. The invariant is that a tenant must never be able to choose an object key and thereby choose another tenant's object.

The tenant contract comes before the bucket

Consider a bounded production scenario. A logistics customer uploads images from a damaged pallet, the worker service creates an AI-generated annotated image, and the UI asks for a download. The request carries a tenant ID and an object key. A naive handler checks that the user is logged in, then signs whatever key arrived in the request. A key such as tenants/acme/loads/42/result.png can become a cross-tenant read if the handler never proves that the authenticated tenant owns it.

The fix is not a more complicated storage product. Store ownership in the application database, derive the key from an internal object record, and authorize that record before asking storage for a signed URL. If the object is gone, expired, or still being generated, return the application-level state; do not manufacture a link. The storage credential stays server-side.

Here is the shape of that path in Go. The interfaces are deliberately generic so the access decision is testable without uploading real media.

package downloads

import (
    "context"
    "errors"
    "fmt"
    "time"
)

var ErrNotFound = errors.New("image not found")
var ErrForbidden = errors.New("image belongs to another tenant")

type Image struct {
    TenantID string
    ObjectKey string
    Ready bool
}

type Catalog interface {
    FindImage(ctx context.Context, imageID string) (Image, error)
}

type Signer interface {
    SignGet(ctx context.Context, objectKey string, expires time.Duration) (string, error)
}

func CreateDownloadLink(ctx context.Context, catalog Catalog, signer Signer,
    tenantID, imageID string, lifetime time.Duration) (string, error) {
    image, err := catalog.FindImage(ctx, imageID)
    if err != nil {
        return "", fmt.Errorf("find image: %w", err)
    }
    if image.TenantID != tenantID {
        return "", ErrForbidden
    }
    if !image.Ready {
        return "", ErrNotFound
    }
    return signer.SignGet(ctx, image.ObjectKey, lifetime)
}
Enter fullscreen mode Exit fullscreen mode

The important test cases are cross-tenant access, an unready result, a missing catalog record, and a key that contains path-like input from the client. The last case should not reach the signer at all. I would also record the tenant, image ID, request outcome, and link lifetime in audit logs while excluding the signed URL itself; URLs are credentials for their validity window.

That last detail is easy to miss. A signed URL is bearer authority. It should not be copied into ordinary application logs, analytics events, or support screenshots.

How should an AI app verify private image storage and signed download links?

Start with the requirements that survive a vendor change. For each image, write down the tenant owner, lifecycle state, retention rule, maximum object size, expected read pattern, and deletion behavior. Then define the service-level objectives: upload completion, download availability, authorization latency, and maximum age for failed-output cleanup. Capacity planning follows from object count and retained bytes, not from the number of model requests alone.

For the logistics workflow, the object key should be an opaque application-generated identifier beneath a tenant-scoped prefix, for example tenant/<internal-id>/asset/<random-id>. A prefix is useful for organization and bulk operations; it is not permission by itself. Authorization still comes from the database record and the authenticated principal.

Keep uploads out of the application process when the media is large. The app can create an upload authorization, the browser or edge client can send bytes directly to private storage, and the app can mark the object ready only after it validates size, content type, checksum policy, and tenant ownership. XMLHttpRequest upload progress events can support an honest progress indicator in a browser, but progress is not proof that the object is safe to publish.

Download expiry needs the same discipline. A short lifetime limits replay, but it does not revoke a URL that has already been issued. If a user is removed from a tenant, the application should stop issuing new links and the product should decide how quickly existing links may remain valid. Browser caching is another clock: align Cache-Control with the access contract so a client does not retain an image longer than the product intends.

The upload lifecycle is where reliability fails

The first failure mode is authorization by naming convention. A tenant prefix can reduce accidental collisions, but trusting a client-provided prefix is equivalent to letting the client pick the account. The second is proxying every byte through the app because it feels safer. That pushes bandwidth, connection count, retries, and backpressure into the service that is also handling identity and job coordination.

The third is signing before the object is ready. An AI pipeline often has intermediate states: requested, uploaded, scanned, transformed, published, and deleted. A link endpoint should have a deliberate policy for each state. “The object exists” is weaker than “this object is approved for this tenant to download.”

In the damaged-pallet workflow, that distinction changes the incident timeline. The upload may complete while the annotation job is still running; the annotation may complete while a content check is pending; a customer may delete the load while a browser still has a retry queued. If the system treats each storage event as a publish event, a link can be issued for an intermediate artifact, a delete can race with a retry, and an operator may see a successful byte transfer that should never have been customer-visible. A small state machine in the catalog makes those transitions explicit: only published can be signed, deleted is terminal, and every worker transition is conditional on the current record version. That is a governance control as much as a coding detail, because it gives support and on-call staff one authoritative answer when the storage listing and the product UI disagree. I would test the transitions with duplicate notifications, delayed jobs, and a retry after deletion before I trusted any throughput result.

The fourth is retry ambiguity. A timed-out upload can leave the client unsure whether storage received the bytes. Retrying with a stable object identity and an application record prevents duplicate assets from becoming an accounting problem. Cleanup should cover failed outputs and abandoned multipart work, with an owner, an alert, and a measurable age threshold. A daily cleanup job is not an SLO unless somebody measures its lateness.

Finally, do not hide the tail in averages. Track p95 and p99 upload and download latency, authorization denials, expired-link attempts, orphaned bytes, and bytes per tenant. A storage decision that looks fine at ten tenants may fail tenant isolation operationally when one customer produces a burst of high-resolution media.

What does the buy-versus-build review actually measure?

Choice Strength Cost or boundary to validate
Managed object storage The provider carries much of the storage infrastructure operation Egress, request, retention, region, and access-policy terms must fit the SLO and forecast
Self-hosted object storage More control over placement, networking, and operating policy The team owns disks, replication, upgrades, recovery tests, capacity planning, and on-call response
Application proxy delivery Centralizes authorization and response shaping The app carries media bandwidth, connection pressure, retries, and a larger failure domain
Direct private-storage delivery Moves bulk bytes away from the app after authorization Signed-link expiry, cache behavior, upload completion, and observability need explicit design

Managed storage is usually the least complex fit when the platform team does not already operate durable storage, but “managed” does not mean “someone else owns the incident.” The team still owns the tenant authorization check, the link lifetime, cleanup, quotas, and the read SLO. Self-hosting becomes reasonable when existing operational capability and locality requirements outweigh the additional on-call surface.

I’m not sure a universal cheapest option exists without object size, retention, read volume, region, and egress assumptions. Your mileage may vary. A spreadsheet that excludes abandoned uploads and support-driven re-downloads is not a cost model; it is a price comparison with the expensive rows removed.

When should private signed downloads be rejected?

The catch is that private storage plus signed links is not a complete public image-delivery system. It is unsuitable when the requirement is a permanent anonymous URL, open-web hotlinking, or a cacheable public gallery. Use a public-serving architecture for those assets, with a separate decision about moderation and origin protection.

It is also a poor fit when the system needs immutable regulatory records, object-level write coordination, or rich server-side metadata queries that the chosen storage layer does not provide. In those cases, add the required database, retention mechanism, or indexing layer, or choose storage whose native controls match the requirement. Do not make the download endpoint pretend to be a records system.

The same separation helps with cost. Keep transient model outputs on a defined retention schedule, preserve business records under a different policy, and make quota enforcement tenant-aware. A deletion worker should be idempotent, observable, and safe to retry; a missing object should not turn cleanup into an incident.

The practical review question is therefore narrower than “which storage is cheapest?” Ask: can we prove tenant ownership before every link, move large bytes without app proxying, measure the resulting SLOs, and delete abandoned work on time? If the answer is yes, private object storage with temporary signed downloads is a sound baseline for this logistics workflow. If the answer is no, changing vendors will not repair the missing control.

Further reading

Top comments (0)