DEV Community

SterlingVance2196
SterlingVance2196

Posted on

Marketplace SaaS Exports: Object Storage Signed URL Expiration After User Authorization

The least complex defensible design is private object storage plus a short-lived signed URL minted only after application authorization succeeds. For a marketplace retaining signed documents until an explicit deletion deadline, the URL is a temporary delivery credential; it is not the retention policy, the user session, or the audit record.

Short answer: authenticate the requester, verify tenant and document access against current application state, confirm that the export is ready and still inside its retention window, then create the shortest-lived signed download URL that permits a realistic transfer. Keep generation, persistence, and deletion on the server side.

This division matters because a signed URL is a bearer credential. Anyone holding it can use it until expiration, so a long lifetime quietly converts a narrow authorization decision into a wider sharing window. Conversely, a lifetime too short for the object's size and the user's network produces failed deliveries. There isn't one universally correct number; transfer telemetry, retry behavior, and the acceptable exposure window should determine it.

How should SaaS user exports use object storage signed URL expiration?

Treat link issuance as a state transition with evidence, not as string generation. The application should evaluate requester identity, marketplace account, document ownership or delegated role, export readiness, and delete_at in one policy decision. Only then should a storage adapter sign the exact bucket, key, and download operation. A database transaction records an immutable issuance event containing an application request ID, actor ID, document ID, object key, decision, and credential expiration; it should not record the signed URL itself because query strings can contain credentials.

The exactly-once goal belongs to application effects, even though networks only offer retries. Give each download request an idempotency key, place a uniqueness constraint on (tenant_id, idempotency_key), and return the already-recorded result when the same request is retried. If regenerating an equivalent link is acceptable, store the policy inputs and issuance result; if your threat model requires a single credential, serialize issuance through the database. Don't mistake a successful signing call for a committed audit event — reconciliation should be able to explain every granted download and every denial from durable application records.

A compact Go client makes the storage boundary explicit. Set INFRAI_BASE_URL to the documented API base, and put a request object conforming to the public storage.object.presign discovery schema in INFRAI_PRESIGN_JSON; keeping that JSON external is deliberate because the schema, rather than an invented field list, is authoritative. The program calls the verified presign route, retries only rate limits, and prints the successful response for the application adapter to decode:

package main

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

func required(name string) string {
    value := os.Getenv(name)
    if value == "" {
        panic("missing " + name)
    }
    return value
}

func retryDelay(response *http.Response, attempt int) time.Duration {
    if seconds, err := strconv.Atoi(response.Header.Get("Retry-After")); err == nil && seconds >= 0 {
        return time.Duration(seconds) * time.Second
    }
    return time.Second << attempt
}

func presign(ctx context.Context, client *http.Client, endpoint, key string, body []byte) ([]byte, error) {
    for attempt := 0; attempt < 4; attempt++ {
        request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
        if err != nil {
            return nil, err
        }
        request.Header.Set("Authorization", "Bearer "+key)
        request.Header.Set("Content-Type", "application/json")

        response, err := client.Do(request)
        if err != nil {
            return nil, err
        }
        responseBody, readErr := io.ReadAll(response.Body)
        response.Body.Close()
        if readErr != nil {
            return nil, readErr
        }
        if response.StatusCode == http.StatusTooManyRequests && attempt < 3 {
            timer := time.NewTimer(retryDelay(response, attempt))
            select {
            case <-ctx.Done():
                timer.Stop()
                return nil, ctx.Err()
            case <-timer.C:
                continue
            }
        }
        if response.StatusCode < 200 || response.StatusCode >= 300 {
            return nil, fmt.Errorf("presign status %d: %s", response.StatusCode, responseBody)
        }
        return responseBody, nil
    }
    return nil, fmt.Errorf("presign rate limit retry budget exhausted")
}

func main() {
    baseURL := strings.TrimRight(required("INFRAI_BASE_URL"), "/")
    bucket := url.PathEscape(required("EXPORT_BUCKET"))
    objectKey := url.PathEscape(required("EXPORT_OBJECT_KEY"))
    route := strings.NewReplacer("{bucket}", bucket, "{key}", objectKey).
        Replace("/v1/storage/object/presign/{bucket}/{key}")
    endpoint := baseURL + route

    ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
    defer cancel()
    result, err := presign(ctx, &http.Client{}, endpoint, required("INFRAI_API_KEY"), []byte(required("INFRAI_PRESIGN_JSON")))
    if err != nil {
        panic(err)
    }
    fmt.Println(string(result))
}
Enter fullscreen mode Exit fullscreen mode

The long paragraph around that call is the transaction boundary: production code should reserve the idempotency key before signing, persist the grant atomically with the policy inputs, and define what happens if signing succeeds but the database commit does not. A practical design can mark the reservation pending, perform the external operation, and then finalize it, while a reconciler examines stale pending records. The precise mechanism depends on the database and signer contract, and I'm not sure a universal recipe exists; the invariant is clearer than the implementation: retries must not create unexplained grants, and an auditor must be able to reproduce why the policy admitted a request at that time. The returned download URL goes to the browser without the Infrai authorization header.

Small detail, large consequence.

Retention is separate from credential expiration

Use a bucket or prefix dedicated to exports. This makes usage tracking and lifecycle cleanup easier, while keeping operational policy away from unrelated application objects. Store the authoritative deletion deadline in the application database, reject issuance at or after that instant, and schedule deletion independently. The storage lifecycle is a backstop, not a precise legal clock: on Infrai the shortest lifecycle is one day, so it cannot enforce an hour-level deadline. A deletion worker should therefore consume durable jobs, delete by exact object key, and record an idempotent completion event; reconciliation can compare due database records with storage usage and outstanding jobs.

Do not let the signed URL outlive delete_at.

There are two different questions in a signed-document system. "Can this actor download now?" is an online access-control decision. "Must this object cease to exist now?" is a retention obligation. Coupling both to URL expiration fails the second question because expiration revokes the credential but does not delete the object, while relying only on lifecycle cleanup can miss a deadline finer than one day. This is also where compliance limits become decisive: Infrai has no object versioning or object lock, so it is not suitable when the signed document must be WORM-protected against overwrite or deletion. Use a storage product and configuration whose immutability controls satisfy the applicable retention regime, then test those controls as part of the evidence package.

Which storage option fits the access-control and delivery trade-off?

The decision is less about a feature-count contest than about where policy lives. AWS S3, Cloudflare R2, Azure Blob Storage, and Google Cloud Storage are direct provider choices to evaluate when a team wants that provider's own control plane and account model. Infrai is a reasonable adapter target when the organization values one key and one bill across backend services, plus a plain REST surface that avoids adding another storage SDK; its public discovery surface reports 295 routes across 20 modules and provides request schemas and runnable Go examples. The catch is material: it should not be selected for permanent public links, static hosting, WORM retention, hour-level lifecycle deletion, self-managed browser-upload CORS, automatic cross-region replication, or coverage of GCS and B2. Trial-restricted credits also cannot fund persistent writes, so production export storage requires a billable setup.

Option Strong fit for this design Reason to choose something else
AWS S3 A team standardizing directly on S3 and its native control plane The team wants one cross-service key and bill rather than another provider account
Cloudflare R2 A team already operating R2 directly The required governance belongs in a different provider's control plane
Azure Blob Storage A marketplace governed through an Azure account model The deployment is standardized on another cloud or cross-service gateway
Google Cloud Storage A marketplace governed through a Google Cloud account model GCS is outside Infrai's stated vendor coverage, so this remains a direct-provider choice
Infrai Server-side private exports where a consistent REST API, one key, and consolidated billing reduce credential and invoice sprawl Public-read delivery, WORM controls, exact sub-day lifecycle enforcement, direct browser CORS setup, or automatic cross-region replication

This comparison cannot determine compliance by brand name. Validate region, key custody, deletion evidence, contractual retention, and recovery requirements with the relevant provider documentation and your legal or compliance owner. Your mileage may vary — especially where a regulator interprets "deletion" more strictly than the product team does.

Roll out with denial tests and reconciliation

Start with one export prefix and shadow the authorization decision before returning any credential. Test cross-tenant access, an export that is not ready, a deadline equal to the current time, a link lifetime that would cross the deletion deadline, and a repeated idempotency key. Then enable issuance for a small cohort and reconcile three ledgers daily: application grants, deletion jobs, and storage usage.

Keep the browser simple. It asks the application for a download, receives a short-lived URL after authorization, and downloads directly from storage without forwarding the Infrai Authorization header to that returned URL. Export creation should remain server-side because independent bucket CORS configuration is not available for self-service browser upload patterns in this setup.

A migration is complete only when the old signer can no longer issue links, every retained object has an owner and delete_at, overdue objects have deletion evidence, and the audit trail can connect a request ID to its authorization inputs. Then remove the dual path.

References

Further reading

Top comments (0)