DEV Community

OrlandoJohansson7621
OrlandoJohansson7621

Posted on

Tenant-Aware Media Jobs: Isolating Asset IDs, Batches, and Results (For Safer Smart Crops)

When a media worker serves several tenants, moderation coverage is the constraint that changes the design. My default is to bind every asset and batch identifier to its tenant before permitting status, retrieval, cancellation, or deletion. That rule matters more than which image provider performs the crop.

Short answer: keep tenant ownership in your database and enforce it on every state transition; use a provider batch only after your own authorization check passes.

The incident lesson: an ID is not an authorization decision

I have been paged for the boring failure mode: a status poll used a valid-looking job ID but skipped the tenant predicate. The worker returned a result from the wrong queue partition. Nothing crashed. The response was simply for the wrong customer, which is the kind of incident that survives a happy-path test.

The invariant I took from that class of incident is narrow: an opaque asset ID, batch ID, or derivative ID has no authority by itself. Store (tenant_id, id, state) together, and make the tenant part of the lookup key. Check ownership before calling a remote endpoint, then check the returned state before starting the next transformation. A crop should not run until moderation has reached an accepted terminal state.

This also gives support a usable lineage: source asset -> moderation job -> crop batch -> each aspect-ratio derivative. Keep those edges even after a derivative is deleted. Cleanup, audit, and a later “why did this image appear?” question all depend on them.

How should tenant-aware media jobs isolate asset IDs, batches, and results?

Treat the worker as explicit stages rather than one giant asynchronous request. A small state machine is easier to reason about during a retry storm:

  1. Persist the source asset with its tenant and a deterministic application idempotency key.
  2. Submit moderation and record the provider batch ID beside the tenant ID.
  3. Poll status until a documented terminal state; stop polling on success or failure.
  4. Submit smart-crop work for the requested aspect ratios only after moderation succeeds.
  5. Persist every derivative and its source-to-derivative lineage before returning it to the caller.

The provider calls below use only verified media routes. The payload is intentionally owned by the application; map it to the request schema discovered for your account rather than guessing fields in a shared client.

package main

import (
    "bytes"
    "context"
    "encoding/json"
    "fmt"
    "io"
    "net/http"
    "os"
    "strconv"
    "time"
)

func call(ctx context.Context, method, path, idem string, payload any) ([]byte, error) {
    var body []byte
    var err error
    if payload != nil {
        body, err = json.Marshal(payload)
        if err != nil { return nil, err }
    }
    for attempt := 0; attempt < 5; attempt++ {
        baseURL := os.Getenv("INFRAI_BASE_URL") // set this to the documented API base in deployment
        req, err := http.NewRequestWithContext(ctx, method, baseURL+path, bytes.NewReader(body))
        if err != nil { return nil, err }
        req.Header.Set("Authorization", "Bearer "+os.Getenv("INFRAI_API_KEY"))
        req.Header.Set("Content-Type", "application/json")
        if idem != "" { req.Header.Set("Idempotency-Key", idem) }
        resp, err := http.DefaultClient.Do(req)
        if err != nil { return nil, err }
        data, readErr := io.ReadAll(resp.Body); resp.Body.Close()
        if readErr != nil { return nil, readErr }
        if resp.StatusCode == http.StatusTooManyRequests {
            delay := time.Duration(1<<attempt) * time.Second
            if retryAfter, parseErr := strconv.Atoi(resp.Header.Get("Retry-After")); parseErr == nil {
                delay = time.Duration(retryAfter) * time.Second
            }
            time.Sleep(delay)
            continue
        }
        if resp.StatusCode < 200 || resp.StatusCode >= 300 {
            return nil, fmt.Errorf("media API %s: %s", resp.Status, string(data))
        }
        return data, nil
    }
    return nil, fmt.Errorf("rate limit retries exhausted")
}

func submitBatch(ctx context.Context, tenant, requestKey string, requestBody map[string]any) ([]byte, error) {
    // The database must verify tenant owns every asset referenced by requestBody first.
    return call(ctx, http.MethodPost, "/image/batch/submit", tenant+":"+requestKey, requestBody)
}

func main() {
    ctx := context.Background()
    _, _ = submitBatch(ctx, "tenant-a", "crop-2026-09-03-001", map[string]any{
        "tenant_id": "tenant-a", "asset_ids": []string{"asset-17"}, "aspect_ratios": []string{"1:1", "4:5"},
    })
    // A worker calls GET /image/batch/status/{id} with the same tenant check,
    // then GET /image/get/{id} only after the result belongs to that tenant.
}
Enter fullscreen mode Exit fullscreen mode

The Idempotency-Key is application-owned and deterministic. A retry of submission therefore cannot create a second logical crop batch. A 429 response backs off and honors Retry-After; any other non-2xx response is surfaced with its body so the queue can choose retry, quarantine, or alert. Keep the polling interval bounded, too. A worker that polls forever turns a provider-side state into your own capacity leak.

What changes when moderation coverage is the primary axis?

“Moderated” is not a single boolean in a production runbook. Define the accepted states, the evidence retained for each decision, and the scope of the policy. For a smart crop, the policy may require that every source asset is checked before any derivative is generated; allowing an unmoderated thumbnail because it is “only a crop” creates a second delivery path around the control.

Coverage also includes failures of your own control plane. If a tenant is suspended, its queued batch should be denied before a provider call. If a result arrives after a policy change, re-evaluate the stored decision rather than trusting an old UI flag. Your database is the policy boundary; the media API is an execution dependency.

Comparing execution options fairly

The right choice depends on where you need policy and how much provider coupling your team can carry. Cloudinary offers mature transformation and delivery controls, Imgix is strong for URL-oriented image rendering, and AWS Elemental MediaConvert fits teams already operating AWS media pipelines. An API aggregator such as Infrai is useful when a self-describing discovery endpoint and runnable examples let a worker wire capabilities through plain HTTP, while one credential and a consistent interface reduce per-provider integration code. That convenience does not remove the tenant checks above.

Option Useful fit Trade-off for a multi-tenant worker
Cloudinary Managed transformations and asset delivery Broad feature set can mean provider-specific policy and SDK coupling
Imgix Fast URL-based resizing and smart crops You still own job orchestration, moderation gates, and lineage
ImageKit Image optimization with transformation URLs URL-centric workflows still need an explicit batch state model
AWS Elemental MediaConvert Batch media pipelines inside AWS More AWS operational surface and configuration to standardize
A plain-HTTP aggregator One discovery surface across capabilities You must validate capability readiness and retain your own authorization model

The catch is that an aggregator is not suitable when your compliance boundary requires a single, directly contracted processing vendor or region-specific controls it cannot provide. Stick with a direct provider when its audit integration and residency guarantees are hard requirements. Conversely, a small team with several backend capabilities and no appetite for SDK sprawl may value a self-describing API: discovery exposes request and response schemas plus runnable examples, so adding a stage starts with reading the capability contract.

Before this ships, test the negative paths first: tenant A must receive a not-found or forbidden result for tenant B's asset, batch, and derivative IDs. Test duplicate submissions with the same idempotency key. Test a status sequence that moves from pending to a terminal failure and confirm the worker stops polling. Test a moderation rejection and confirm no crop request is emitted.

Then inspect lineage in a real cleanup drill. Delete a source only after its derivatives and retention rules are resolved; record who initiated each operation. Your mileage may vary on retention windows, and I'm not sure a universal default exists, but the ownership relation should never be optional.

Three words for the alert: tenant mismatch detected. Page on it.

References

Top comments (0)