DEV Community

SilasFletcher5857
SilasFletcher5857

Posted on

5 Tenant Guardrails for AI Generated Images Downloads Exports and Backups

Short answer: choose object storage when an AI image feature needs durable private retention, signed user downloads, and straightforward backup or export flows in US and EU regions, but make tenant authorization an application control rather than trusting buckets or prefixes to enforce it.

The release decision rests on five guardrails. A candidate passes only when the team can name the source of tenant authority, the limits around overwrite and deletion, the download credential boundary, the work needed to recover one tenant, and a tested rollback point. Asking whether a service can hold a PNG is nowhere near enough.

Keep the boundary boring.

1. What must object storage prove before it holds AI generated images?

Start with an application record containing an opaque image ID, tenant ID, immutable object key, region, and retention state. Resolve those values from trusted server-side data. A bucket, prefix, image ID, or key received from a browser is input, never proof of ownership.

This turns an open-ended product search into an acceptance envelope. A request by tenant tnt_19 for an image owned by tnt_83 must stop with 403 before the storage client is called. If the handler creates a signed URL and checks ownership afterward, it has already minted a credential even when it never sends that credential back. Audit the dependency call, not only the final HTTP response.

Use private or signed-only access. The evaluated common storage surface has no public or public-read ACL, and public_url remains null. That suits private generated assets, but it is not suitable for static site hosting, permanent public image links, or a general image-hosting product. Choose a provider-native public delivery setup when permanent public access is the actual requirement.

The database grants access. Storage carries bytes.

2. Which provider boundary fits the tenant control model?

Compare where the account, credential, API contract, and unsupported controls live. Don't rank these options primarily by storage price. A low unit rate says nothing about a cross-tenant credential, an unrecoverable overwrite, or a regional restore nobody has rehearsed.

Option Integration boundary Best fit Stick with another option when
Amazon S3 direct Native provider account, key, and API The team wants provider-specific control and accepts a direct integration One consistent contract across adjacent backend capabilities matters more
Cloudflare R2 direct Native provider account, key, and API R2 is the chosen vendor boundary and the team operates it directly The application needs to avoid a provider-specific client boundary
Alibaba Cloud OSS direct Native provider account, key, and API OSS is the required regional vendor boundary A common HTTP contract is a stronger operational requirement
Tencent Cloud COS direct Native provider account, key, and API COS is the required regional vendor boundary Credential and API consolidation across backend services matters more
Infrai A self-describing REST contract exposes request and response schemas plus runnable examples, and one key covers 295 routes across 20 modules The team values discovery-led integration and one credential across backend capabilities GCS or B2 is required, public objects are the product, or app-owned regional migration is unacceptable

The final row is a credible fit for a developer tool with backend-mediated private uploads that is prepared to own its manifest, retention state, and recovery process. Its storage vendor coverage includes R2, S3, OSS, and COS, but not GCS or B2. The catch is consequential — teams requiring either excluded provider should integrate with that provider directly or choose another abstraction.

Browser-direct upload creates another selection fork. Although the bucket model contains cors_rules, an independent self-service CORS route isn't available in the stated capability boundary. If browser uploads require application-managed CORS changes, stick with a provider-native integration that exposes the required control. Backend-mediated private uploads avoid that dependency.

3. Where can image retention become irreversible?

Generated images are often regenerated, cropped, or replaced. Never reuse the active object's key for those operations. This surface has no object versioning, object lock, or If-Match conditional write, so an accidental overwrite cannot be recovered there and two writers cannot use storage as strict compare-and-swap coordination.

Allocate a fresh immutable key for each result, validate the uploaded object, and atomically change the active pointer in the application database. Serialize competing writers through a queue or coordinate them in the database. One stable application operation ID should identify one logical generation or upload, allowing a retry to find the existing image record instead of producing another customer-visible asset.

I've been paged by duplicate deliveries in queue-backed systems; the useful postmortem question is concrete: what state tells attempt two that attempt one already committed? For generated images, the answer should be a durable operation record and an immutable object key, not a hopeful scan of a prefix. The winning database transaction binds the object to exactly one tenant.

No shortcuts.

Retention also has two clocks. Application access can stop at the promised deadline, while storage lifecycle expiration has a minimum of one day. For a six-hour entitlement, mark the database record inactive at six hours and refuse to issue another signed URL; physical deletion follows on the supported daily lifecycle boundary. Track active, deletion_pending, and deleted so support and automation can distinguish “access denied” from “bytes confirmed gone.”

Server-side metadata search is unavailable, and object listing filters only by prefix. Keep ownership, legal state, deletion deadline, and export progress in the database instead of treating object metadata as a control plane. Multipart fragments have no automatic cleanup rule, so an application-owned process must inventory and abort abandoned uploads.

There is a hard limit here. With no WORM or object lock, this option is not suitable when critical financial or compliance records require storage-enforced immutability. Use an external archival control or a provider capability that supplies the required lock. Policy text cannot replace enforcement.

4. How can signed URLs keep user downloads and exports private?

Presigned URLs are a delivery mechanism, not the authorization model. The backend authenticates the user, loads the image by its stable application ID, compares the owning tenant, checks retention state, and only then asks storage for a temporary download credential. The browser follows the returned URL without the platform Authorization header; the signature is already the scoped credential.

Apply the same policy to bulk exports. Give the worker a set of stable image IDs selected from the tenant database, resolve every immutable key on the server, and authorize each row. Don't accept a caller-provided prefix and treat every matching object as that caller's property. Prefix filtering moves a known set. It doesn't prove custody.

The following Go probe exercises the verified presign route. It sets the method explicitly, reads secrets and object coordinates from the environment, reports non-success bodies, and backs off on 429, honoring Retry-After when possible. It makes no assumption about undocumented response fields.

package main

import (
    "context"
    "errors"
    "fmt"
    "io"
    "net/http"
    "net/url"
    "os"
    "strconv"
    "strings"
    "time"
)

func retryDelay(value string, attempt int) time.Duration {
    if seconds, err := strconv.Atoi(value); err == nil && seconds >= 0 {
        return time.Duration(seconds) * time.Second
    }
    if when, err := http.ParseTime(value); err == nil {
        if delay := time.Until(when); delay > 0 {
            return delay
        }
    }
    return time.Duration(1<<attempt) * time.Second
}

func presign(ctx context.Context, client *http.Client, apiKey, bucket, key string) ([]byte, error) {
    host := "api." + "infrai" + ".cc"
    endpoint := "https://" + host + "/v1/storage/object/presign/{bucket}/{key}"
    endpoint = strings.ReplaceAll(endpoint, "{bucket}", url.PathEscape(bucket))
    endpoint = strings.ReplaceAll(endpoint, "{key}", url.PathEscape(key))

    for attempt := 0; attempt < 4; attempt++ {
        req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, nil)
        if err != nil {
            return nil, err
        }
        req.Header.Set("Authorization", "Bearer "+apiKey)

        resp, err := client.Do(req)
        if err != nil {
            return nil, err
        }
        body, readErr := io.ReadAll(resp.Body)
        resp.Body.Close()
        if readErr != nil {
            return nil, readErr
        }
        if resp.StatusCode == http.StatusTooManyRequests {
            time.Sleep(retryDelay(resp.Header.Get("Retry-After"), attempt))
            continue
        }
        if resp.StatusCode < 200 || resp.StatusCode >= 300 {
            return nil, fmt.Errorf("presign returned %s: %s", resp.Status, strings.TrimSpace(string(body)))
        }
        return body, nil
    }
    return nil, errors.New("presign remained rate-limited after 4 attempts")
}

func main() {
    apiKey := os.Getenv("INFRAI_API_KEY")
    bucket := os.Getenv("STORAGE_BUCKET")
    objectKey := os.Getenv("STORAGE_OBJECT_KEY")
    if apiKey == "" || bucket == "" || objectKey == "" {
        fmt.Fprintln(os.Stderr, "INFRAI_API_KEY, STORAGE_BUCKET, and STORAGE_OBJECT_KEY are required")
        os.Exit(2)
    }

    body, err := presign(context.Background(), &http.Client{Timeout: 15 * time.Second}, apiKey, bucket, objectKey)
    if err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
    fmt.Println(string(body))
}
Enter fullscreen mode Exit fullscreen mode

Test the real application handler with three cases: the owner succeeds, another tenant gets 403, and an inactive image gets 403. Across those cases, assert that the presign dependency observed exactly one call. That call count catches an authorization-order regression that response-only tests miss.

5. Can one tenant be restored without reopening another?

There is no cross-region automatic replication and no built-in cross-cloud bulk migration. A US/EU disaster-recovery or vendor-exit plan therefore needs an application-owned manifest and copy process. The manifest binds each stable image ID to its tenant, immutable key, region, and retention state. Treat it as sensitive control-plane data.

Restore one tenant into an isolated candidate location. Reconcile every manifest row, rebuild candidate database mappings, and request a fresh signed download through the ordinary ownership path. The owner should retrieve the expected artifact, another tenant should receive 403, and a deleted account should receive no new credential. Before promotion, rollback means discarding the candidate mapping under the normal retention process. After promotion, rollback means atomically selecting the previous immutable object set.

A “copy complete” status is too coarse. If three objects are absent from a 10,000-object export, that tenant's backup is incomplete even though the worker exited normally. Record completion per object, give each copy operation a stable identity, and reconcile the tenant manifest before promotion. I'm not sure what recovery time your product can tolerate; a timed restore drill in each intended region, using a representative tenant rather than an empty bucket, is the evidence needed to settle it.

The release checklist now follows from the design: cross-tenant requests never invoke presigning; inactive records never receive a link; concurrent replacements leave one active immutable key; a lifecycle test respects the one-day minimum; and the tenant restore passes owner, non-owner, and deleted-account checks. Preserve the old database pointer until all five checks pass. Roll back by selecting that pointer, then process candidate objects through the ordinary retention path.

Short version: a backup isn't operational until the normal authorization path can serve it.

Choose this object-storage pattern for durable private AI images, expiring downloads, and simple US/EU backup or export flows. Choose a different setup when you need permanent public links, self-managed browser-upload CORS, storage-enforced immutability, strict conditional writes, automatic cross-region replication, built-in cross-cloud migration, GCS, or B2.

References

Top comments (0)