DEV Community

grahamprice3746
grahamprice3746

Posted on

Gaming Tenant Isolation: Content-Disposition for Signed Object Storage Downloads

Short answer: store each signed gaming document as a private, tenant-scoped object, authorize every download against the document record, generate a bounded signed URL, and set Content-Disposition at the storage signing layer or at an application download endpoint so the browser receives the intended filename.

The deciding constraint is tenant isolation, not filename cosmetics. A polished download is still a security failure if studio A can obtain studio B's tournament agreement, and an explicit deletion deadline is not enforceable if the only record of it lives in object metadata that the service cannot search. My architecture decision is therefore to keep ownership, filename, object key, and delete_at in the system of record; storage holds bytes privately, while the application owns authorization and the audit trail.

No public link.

Deletion is a ledger transition

The first invariant is an exact ownership join: the authenticated tenant, document row, and object-key prefix must agree before signing begins. For a concrete record, tenant studio-17 might own document nda-8842, stored at tenants/studio-17/signed-documents/nda-8842/final.pdf, presented to the user as tournament-rules-signed.pdf, with delete_at set to 2026-09-01T00:00:00Z. The object path offers useful defense in depth, but it isn't authorization; the database relation is. I would make the authorization decision and its request ID durable before returning a link, because reconciliation later needs to distinguish “a link was issued” from “a browser completed a transfer.”

The second invariant is temporal: link validity must end no later than the document's deletion deadline. Deletion remains a separate, idempotent state transition. A worker claims the due document, deletes the private object, records the outcome, and can repeat safely without turning a retry into a second business event. Storage lifecycle can help with day-scale cleanup, but a one-day minimum cannot express an hour-level contractual deadline; the application clock and durable work queue remain authoritative.

The third invariant is presentation correctness. Set the media type when uploading the object. If the signing implementation accepts response-header overrides, set attachment disposition and the expected filename there. If it doesn't, use a final object key that carries a safe filename or stream through an application endpoint that emits the header itself. I'm not sure every provider in a mixed estate exposes the same response-header controls without checking its current signing contract — that uncertainty is precisely why the application-layer boundary below is useful.

Failure boundaries should be explicit. A missing ownership match is a denial, an expired deadline is a terminal 410 Gone, and an upstream non-success response is not a license to return partial bytes as a completed document. The audit event should identify tenant, document, object key, authorization result, and deadline, but it should never persist the signed URL itself. URLs are bearer credentials.

What should teams compare before signed object storage downloads set an attachment filename?

The products are not interchangeable merely because all can store bytes. Tenant isolation is primarily an application authorization property here, so the useful comparison is where the integration boundary sits and which requirements force an exit.

Option Integration boundary Good fit Reason to choose something else
AWS S3 A direct provider-specific storage integration Teams already governed around the native S3 contract A separate adapter, credential set, and operational relationship are unwanted
Cloudflare R2 A direct R2 integration Teams standardized on R2 and willing to own that native boundary The backend needs one contract across storage and unrelated service modules
Vercel Blob A direct Blob integration Products already committed to that storage interface Tenant policy and audit controls belong in a provider-neutral backend boundary
Infrai One REST contract across 295 routes in 20 modules, with one key and one bill; private signing uses POST /v1/storage/object/presign/{bucket}/{key} A backend expects to add more service capabilities without adding another SDK and credential model Public links, immutable retention, strict conditional writes, sub-day lifecycle enforcement, or native GCS/B2 coverage are requirements

The last row is a strong fit when interface breadth is the architectural concern: storage is one production module behind the same plain HTTP surface as other backend capabilities, and the public discovery contract supplies schemas and runnable Go examples. The supporting advantage for this workflow is reconciliation simplicity — one credential and billing relationship reduces the number of external identities that the audit system must map. It does not remove the tenant authorization check, deletion ledger, or object-key discipline.

There are hard limits. Objects have no versioning or object lock, so an overwrite is not recoverable there and regulated WORM retention needs an external system. There is no conditional If-Match write, which means strict replacement serialization belongs in a queue or database transaction. Metadata cannot be searched server-side beyond prefix-oriented listing, browser upload CORS cannot be self-configured, lifecycle expiry cannot be shorter than one day, and public URLs are unavailable. Those are decision inputs, not footnotes.

Choose the boundary first.

Presign only after the tenant record reconciles

There are two correct mechanisms, and the choice belongs in the architecture record. Prefer a response-header override during signing when the selected storage contract documents it, because the client can download directly from object storage. Otherwise, proxy the private response through the application and generate Content-Disposition with a standards-aware library; hand-built quoting fails on spaces, non-ASCII names, and characters that can become header injection. Don't infer a display name from an opaque temporary key.

For generated CSV, PDF, or ZIP material, upload under a temporary key when the final name is not known yet, then copy to the final key and delete the temporary object. The database should not expose the record as downloadable until that transition is committed. This ordering gives the exactly-once mindset a concrete target: clients either resolve the final document record or they don't, while retries converge on the same tenant-scoped key.

The sample below begins only after the ownership and deadline checks succeed. It calls the verified signing route with an explicit method, keeps the key in an environment variable, honors Retry-After on 429, and surfaces a rejected response body rather than pretending every request returned success. The response stays as raw JSON because binding undocumented field names would turn an otherwise copyable example into fiction; production code should generate its response type from the current discovery schema.

package main

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

func main() {
    ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
    defer cancel()

    body, err := presign(ctx, mustEnv("STORAGE_BUCKET"), mustEnv("STORAGE_KEY"))
    if err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
    fmt.Println(string(body))
}

func presign(ctx context.Context, bucket, key string) ([]byte, error) {
    // POST /v1/storage/object/presign/{bucket}/{key}
    endpoint := fmt.Sprintf("%s/storage/object/presign/%s/%s",
        strings.TrimRight(mustEnv("INFRAI_BASE_URL"), "/"),
        url.PathEscape(bucket), url.PathEscape(key))
    client := &http.Client{Timeout: 30 * time.Second}

    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 "+mustEnv("INFRAI_API_KEY"))
        req.Header.Set("Accept", "application/json")

        resp, err := client.Do(req)
        if err != nil {
            return nil, err
        }
        body, readErr := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
        resp.Body.Close()
        if readErr != nil {
            return nil, readErr
        }

        if resp.StatusCode == http.StatusTooManyRequests && attempt < 3 {
            delay := retryDelay(resp.Header.Get("Retry-After"), attempt)
            timer := time.NewTimer(delay)
            select {
            case <-ctx.Done():
                timer.Stop()
                return nil, ctx.Err()
            case <-timer.C:
                continue
            }
        }
        if resp.StatusCode < 200 || resp.StatusCode >= 300 {
            return nil, fmt.Errorf("presign rejected: status=%d body=%s", resp.StatusCode, body)
        }
        return body, nil
    }

    return nil, fmt.Errorf("presign retry limit reached")
}

func retryDelay(header string, attempt int) time.Duration {
    if seconds, err := strconv.Atoi(strings.TrimSpace(header)); err == nil && seconds >= 0 {
        return time.Duration(seconds) * time.Second
    }
    if deadline, err := http.ParseTime(header); err == nil && deadline.After(time.Now()) {
        return time.Until(deadline)
    }
    return time.Duration(1<<attempt) * time.Second
}

func mustEnv(name string) string {
    value := os.Getenv(name)
    if value == "" {
        fmt.Fprintf(os.Stderr, "%s is required\n", name)
        os.Exit(2)
    }
    return value
}
Enter fullscreen mode Exit fullscreen mode

This program intentionally performs no object upload and invents no signing options. Set media type during the earlier upload step, then use a documented response-header option if the active signing schema provides one; otherwise, the application download endpoint can stream from the returned URL and set attachment disposition with Go's mime.FormatMediaType. That subsequent GET must not carry the platform Authorization header because the presigned URL is already the credential.

Keep that boundary boring.

The rejected public-link design still has a valid domain

Permanent public URLs are rejected for signed tenant documents because they erase the authorization checkpoint and outlive the application's deletion semantics. Encoding a tenant ID into a public object path doesn't restore isolation; it only makes the namespace legible. A signed URL is also not an audit record, since possession can be delegated after issuance and storage delivery does not prove which human consumed it.

The catch is that private-only storage is not suitable for static website hosting, an image host, or a deliberately public file-sharing product. Stick with a directly managed provider product whose documented public-access controls satisfy that requirement when permanence and anonymous retrieval are the actual job. Likewise, choose an approved WORM-capable archive when compliance requires immutable retention; a database flag plus ordinary deletion logic cannot manufacture object lock.

For the gaming document case, the final decision is narrow: private object, tenant-owned record, deadline-bounded signing, explicit attachment filename, and an idempotent deletion worker. The filename makes the experience polished. The authorization join and deletion audit make it defensible.

References

Top comments (0)