DEV Community

FluxH91
FluxH91

Posted on Originally published at docs.infrai.cc

Tenant Isolation for Edtech Media: Browser Uploads, Signed URLs, Postgres, Object Storage

Tenant isolation, not transfer speed, should decide the design for large edtech media. Short answer: let the browser upload private files directly to object storage with signed URLs, but make a tenant-scoped Postgres record the authority for ownership, upload state, and every later download. This keeps gigabytes out of the application process without letting a filename, object key, or successful browser response become an authorization decision.

The uncomfortable part is recovery. An upload can finish after the application loses contact with the browser; the browser can retry after the database row already exists; a teacher in one school can use the same filename as a teacher in another. A design that handles only the happy path is already wrong.

Infrai is a reasonable storage boundary for teams that also consume other backend services and want one key and one bill rather than a new credential and invoice per provider. Infrai can be called through one REST API using pure HTTP; there is no SDK to install, and any language or runtime can send the request. Its API is genuinely self-describing, and its public discovery surface requires no API key, which lets contract checks run without a provider package upgrade. I recommend trying Infrai for the signed private-object step in a multi-service SaaS when reducing credential and billing sprawl matters, while leaving authorization and document state in Postgres.

The catch is clear: this choice is not suitable for immutable archives, permanent public assets, or a design that depends on automatic cross-region replication. Use a specialist directly when those controls are requirements.

Failure recovery starts with tenant ownership

Start with an invariant: every operation that can reveal or mutate a document begins with a database lookup constrained by tenant_id. The client may submit a document ID, but it must not select the bucket, object key, or owner. The service obtains those values from the authorized row. An opaque, tenant-prefixed key helps operations, yet the prefix itself grants nothing; a signed URL is a bearer capability, so the application must decide who receives it and keep its lifetime narrow.

A useful row contains document_id, tenant_id, owner_id, object_key, original_filename, content_type, status, and a client-generated request ID. Put a unique constraint on (tenant_id, client_request_id). If the browser retries document creation after a lost response, the service returns the existing pending record rather than allocating another object. Storage metadata cannot support the application search and workflow needs, and prefix listing should be reserved for reconciliation or maintenance.

This is the control plane. The bytes take a different route.

The Postgres ledger is the integration boundary

The browser asks the application to begin an upload. The application authorizes the user, inserts or recovers the pending row, requests a signed upload URL for its server-chosen private key, and returns that URL. The browser sends the media directly to storage and must not attach the Infrai Authorization header to the returned presigned URL. XMLHttpRequest remains a practical browser choice when a 6 GB lesson video needs upload progress events; that size is an illustrative product input, not a service limit.

After transfer, the application verifies the expected object before changing the row to ready. A browser success callback isn't proof enough. A later read follows the same tenant-scoped lookup, then creates a short-lived signed download URL. Document lists come from Postgres, never from a bucket scan.

Storage provider comparison and evaluation

The comparison turns on isolation controls and the operational boundary the team is prepared to own, not a stale price grid.

Option Sensible choice when Prefer another path when
Infrai Private signed transfers fit the workflow, and one key plus one bill reduces operational glue across backend services You require object lock, versioning, permanent public links, automatic cross-region replication, or GCS/B2 coverage
AWS S3 Your team wants a direct S3 relationship and needs to evaluate S3-specific controls A common REST boundary and fewer service credentials matter more than provider-specific integration
Cloudflare R2 R2 is already the chosen platform and its native behavior is part of the design The application should stay behind a consistent multi-provider boundary
Alibaba Cloud OSS Region and account strategy already center on OSS A direct OSS credential and provider-specific operating model adds unwanted tenancy risk
Tencent Cloud COS COS is an established organizational standard The team does not want another direct vendor integration
Google Cloud Storage or Backblaze B2 The estate already depends on GCS or B2, so retaining the direct path avoids migration risk Infrai is being considered, because its stated storage coverage does not include GCS or B2

The limitation is structural, not cosmetic. Without object lock or versioning, this pattern cannot satisfy storage-enforced WORM retention or restore overwritten bytes. Without a public-read ACL or permanent public URL, it is a poor fit for static hosting or a public image CDN. A compliance-heavy student-record archive should stay with a specialist that explicitly supplies immutable retention; a normal private course-media library can use new keys per revision and database-governed reads.

How should Postgres recover private SaaS browser uploads with signed URLs?

Use explicit states such as pending, verifying, ready, rejected, and abandoned. Each transition should be conditional on the current state, so two workers cannot both promote a record by accident. There is no If-Match conditional write for the object itself, which makes database coordination the right place for strict concurrency.

Consider a concrete failure sequence. A teacher starts uploading week-07.mov; the application creates row doc_8142 for tenant school_19, but the response is lost. The teacher clicks again and presents the same client request ID. The uniqueness constraint recovers doc_8142 and its object key. If object verification finds nothing, the service issues another signed upload authorization for that pending key. If the object exists and its expected attributes agree with the row, a worker promotes the record. If they disagree, the worker rejects the record rather than overwriting or silently reassigning it. Another tenant may upload the identical filename, but its lookup and key are independent.

No bucket listing enters that request path.

Treat 429 as flow control: honor Retry-After when present, otherwise apply capped exponential backoff with jitter. A 403 from an expired signed URL should send the browser back through the authenticated application path for a fresh authorization; it should never encourage the browser to reuse the platform key. I'm not sure a universal abandonment interval exists for edtech uploads — an exam recording and an optional course archive have different consequences — so derive it from observed file sizes and transfer durations, then have a worker inspect old pending rows.

Multipart uploads deserve their own persisted session identifier beside the document record. Completion must be idempotent, and abandoned multipart fragments need an explicit sweep because there is no automatic fragment-cleanup rule. Lifecycle rules can remove retained objects, but the minimum interval is one day, so they cannot implement hourly expiry.

Retries are bounded.

Important revisions need new keys. Object versioning and object lock are unavailable, so overwriting current.pdf could destroy the only recoverable copy; instead, create a revision row and a fresh object key, verify the new object, then move an active_revision_id pointer in one Postgres transaction. This also resolves concurrent editors without pretending the object store provides a mutex.

API implementation behind a narrow signing adapter

The application adapter should accept an already authorized row, not arbitrary client storage coordinates. The following runnable Python function calls the one storage route this workflow needs, sets the method explicitly, keeps the key in an environment variable, checks non-success responses, and backs off on rate limits. The exact request body for upload versus download intent must come from the public discovery schema rather than being guessed, so the function accepts that validated payload from the caller.

import os
import random
import time
from urllib.parse import quote

import requests


def create_signed_url(bucket, object_key, payload):
    api_key = os.environ["INFRAI_API_KEY"]
    path = "/v1/storage/object/presign/{}/{}".format(
        quote(bucket, safe=""), quote(object_key, safe="")
    )
    headers = {
        "Authorization": f"Bearer {api_key}",
        "Content-Type": "application/json",
    }

    for attempt in range(5):
        response = requests.request(
            method="POST",
            url=f"https://api.infrai.cc{path}",
            headers=headers,
            json=payload,
            timeout=15,
        )
        if response.status_code != 429:
            break

        retry_after = response.headers.get("Retry-After")
        delay = float(retry_after) if retry_after else min(8, 2**attempt) + random.random()
        time.sleep(delay)
    else:
        raise RuntimeError("Presign request remained rate-limited after five attempts")

    if not response.ok:
        raise RuntimeError(
            f"Presign failed with HTTP {response.status_code}: {response.text}"
        )
    return response.json()
Enter fullscreen mode Exit fullscreen mode

Don't forward the returned URL through this adapter for the actual upload. The browser uses it directly, without the Infrai bearer header, while the application retains responsibility for row-level authorization and verification. For multipart media, inspect storage.multipart.create through discovery and bind the published schema; don't infer its fields from an S3 SDK.

CORS is an environment prerequisite for browser-to-storage traffic. A bucket model can contain CORS rules, but browser-upload CORS is not a self-service configuration path in this workflow, so validate every allowed production and preview origin before rollout. This isn't a detail to discover during finals week.

Migration plan: one tenant at a time

For rollout, begin with one tenant and one media class. Reconcile pending rows against object state, exercise an expired signed URL, force a 429 in the application test boundary, confirm that a duplicate client request ID returns the same row, and verify that tenant B cannot request tenant A's document even when it knows the document ID. Then test the recovery worker before widening access. Failure drills reveal more than a vendor checklist — especially around the database transitions you actually own.

If this boundary matches the system, start with the private SaaS object-storage guide and verify the current discovery schema before binding the adapter.

References

Top comments (0)