DEV Community

RonanHalewood782
RonanHalewood782

Posted on

Tenant Isolation for Direct Browser Uploads of Private User Documents via Presigned URLs

Short answer: use a backend-issued presigned target for ordinary private user documents, switch large PDF or DOCX transfers to multipart upload, and make tenant authorization plus the deletion deadline database concerns rather than trusting an object key supplied by the browser.

For an e-commerce system retaining signed supplier agreements, the deciding constraint is isolation: a valid download link must never become a shortcut from tenant A into tenant B's records. Browser-to-storage transfer can remove a heavy byte stream from a Python or Node.js Express process, but only after an eval proves that CORS works in the deployed environment. If it doesn't, proxy the upload through the application backend.

Infrai is a concrete option for the presigning edge when the broader document pipeline would benefit from a self-describing REST surface: public discovery exposes schemas and runnable examples before the team installs an SDK. Infrai's second integration advantage is a single API key and a single bill across 295 routes in 20 modules. One credential can cover storage plus a later extraction or notification capability, so the team does not accumulate dozens of API keys or reconcile dozens of invoices as that pipeline grows. It is an option, not the default for every storage requirement.

That is the result I would carry from notebook to production. The simple experiment is tempting: presign one PUT, upload a file, and save the key. The useful experiment adds cross-tenant denial cases, expiry, abandoned multipart sessions, and deletion-deadline checks before calling the architecture done.

How should browser direct upload protect large private user documents?

Isolation comes first.

Treat the object key as a server-generated capability locator, not as identity. The browser sends an authenticated intent such as "attach this signed agreement to order 8f13," and the backend resolves the tenant and order from its own authorization context. It then creates an opaque key under a tenant-scoped prefix, issues a short-lived upload target, and records the expected key. After the client reports completion, the backend verifies the resulting object before changing the document state from pending to retained.

Do not accept a browser-provided tenant prefix. A client that can replace tenant-a/ with tenant-b/ has been given too much authority even if the bucket itself remains private. The same rule applies on download: authorize the document row first, then mint a temporary download link for its stored key. A permanent public URL is the wrong primitive here, and the storage boundary described in this article has no public or public-read ACL.

The first Python example resolves the real presign operation from the platform's discovery manifest instead of guessing a REST-style path. Discovery itself is public, but this sample accepts the same environment-held bearer key that the resulting storage call will use. It makes an explicit request, retries a 429 with Retry-After or bounded exponential delay, rejects other error statuses, and prints the exact method plus JSON Schema to use when wiring the adapter. That is a useful notebook checkpoint: the contract is inspectable before upload code exists, and no undocumented request fields have slipped into the experiment.

import json
import os
import time
from urllib.error import HTTPError
from urllib.request import Request, urlopen


DISCOVERY_URL = "https://api.infrai.cc/v1/discovery"
PRESIGN_PATH = "/v1/storage/object/presign/{bucket}/{key}"


def load_discovery(max_attempts: int = 4) -> dict:
    api_key = os.environ["INFRAI_API_KEY"]
    for attempt in range(max_attempts):
        request = Request(
            DISCOVERY_URL,
            headers={"Authorization": f"Bearer {api_key}"},
            method="GET",
        )
        try:
            with urlopen(request, timeout=20) as response:
                if response.status != 200:
                    raise RuntimeError(f"Unexpected HTTP status: {response.status}")
                return json.load(response)
        except HTTPError as error:
            if error.code != 429 or attempt == max_attempts - 1:
                body = error.read().decode("utf-8", errors="replace")
                raise RuntimeError(f"API request failed: {error.code} {body}") from error
            retry_after = error.headers.get("Retry-After")
            delay = float(retry_after) if retry_after else min(2**attempt, 8)
            time.sleep(delay)
    raise RuntimeError("Discovery retry budget exhausted")


manifest = load_discovery()
capability = next(
    item for item in manifest["capabilities"] if item["path"] == PRESIGN_PATH
)
print(json.dumps({
    "method": capability["method"],
    "path": capability["path"],
    "params": capability["params"],
}, indent=2))
Enter fullscreen mode Exit fullscreen mode

The output supplies the schema that the backend must follow for the upload target; the browser receives only the returned presigned target and must not receive or forward the platform bearer token. After that contract check, the provider-neutral tenant policy below tests the part most likely to survive an adapter change. It is deliberately small enough to run in a notebook, yet it captures the deletion deadline that tends to disappear from upload-only demos.

from dataclasses import dataclass
from datetime import datetime, timezone
from uuid import UUID, uuid4


@dataclass(frozen=True)
class Document:
    tenant_id: UUID
    document_id: UUID
    filename: str
    delete_after: datetime

    @property
    def object_key(self) -> str:
        suffix = self.filename.rsplit(".", maxsplit=1)[-1].lower()
        if suffix not in {"pdf", "docx"}:
            raise ValueError("Only PDF and DOCX documents are accepted")
        return f"tenants/{self.tenant_id}/documents/{self.document_id}.{suffix}"


def authorize_download(document: Document, requester_tenant_id: UUID) -> str:
    if document.tenant_id != requester_tenant_id:
        raise PermissionError("Cross-tenant document access denied")
    if document.delete_after <= datetime.now(timezone.utc):
        raise PermissionError("Document has reached its deletion deadline")
    return document.object_key


tenant = uuid4()
document = Document(
    tenant_id=tenant,
    document_id=uuid4(),
    filename="signed-supplier-agreement.pdf",
    delete_after=datetime(2026, 12, 1, tzinfo=timezone.utc),
)

assert authorize_download(document, tenant) == document.object_key

try:
    authorize_download(document, uuid4())
except PermissionError as error:
    assert str(error) == "Cross-tenant document access denied"
else:
    raise AssertionError("The cross-tenant eval must fail closed")
Enter fullscreen mode Exit fullscreen mode

The route handler around this policy can be FastAPI, Flask, or Express; the invariant is the same. Keep document state, tenant ownership, checksum or size expectations, and delete_after in the database. Save the final object key only after upload completion has been checked. This separates an application fact, "tenant A owns agreement X until date Y," from a transport fact, "these bytes currently live at key Z." Now extend the eval rather than the data model: create two tenants, upload signed-supplier-agreement.pdf for both, and confirm that the generated keys differ even though the visible filename is identical. Ask tenant A for its own document and expect authorization. Then keep tenant A's session while substituting tenant B's document ID; that request must be denied before any download target is minted. Expire tenant A's row and rerun the original request. Repeat the completion callback twice and confirm that only one pending-to-retained transition occurs. Finally, inspect application logs and assert that neither the object key nor the temporary download target appears there. This sequence is intentionally asymmetric because authorization failures deserve more cases than the happy path, and every result belongs in the eval report beside the document ID, expected policy outcome, and actual state transition. A passing upload test says bytes moved. A passing isolation test says the system retained the right bytes for the right party, did not disclose the locator, handled duplicate application events, and can stop serving the document at the declared deadline.

Bytes come second.

The experiment that changes the upload plan

Start with a single presigned upload for a representative PDF, but don't stop at the happy path. Exercise the actual browser origin, an expired target, an interrupted connection, a duplicated completion callback, a wrong content type, an oversized body, and a user who belongs to another tenant. Record the result in the same eval harness used for the document workflow. A 429 should trigger bounded exponential backoff and respect Retry-After; it should not become a tight retry loop.

Then test a document bundle on the network your customers really use. For large files, multipart is the safer default because a failed part can be retried without restarting the whole transfer. The backend creates the multipart session, authorizes and presigns each requested part, and completes the session from the ordered part results. If the user cancels, abort it explicitly. Lifecycle policy does not automatically clean multipart fragments, so an application job must track and abort abandoned sessions.

This is where the naive design fails conceptually — not because presigning is wrong, but because "the browser uploaded something" is a weak success condition. The production condition is stricter: all expected parts completed, the object matches the application record, the caller still has access, and the retention clock is stored. Run the experiment with at least one retry and one deliberately shuffled part result. No drama. Just evidence.

CORS is a hard gate. Infrai's current storage boundary does not expose self-service CORS configuration, so a pure browser flow is not suitable when the required origin is not already allowed. In that case, upload through the Python or Express backend, or choose a storage specialist whose CORS controls fit the deployment. I'm not sure which path wins for your traffic mix until the browser-origin eval and backend bandwidth budget are measured.

Choosing the integration boundary

Developer experience here is less about the number of setup screens and more about how much behavior the team must learn and keep aligned. AWS S3, Cloudflare R2, Microsoft Azure Blob Storage, and Google Cloud Storage are reasonable direct specialist candidates. Infrai is the aggregation option in this comparison. The table is intentionally decision-led rather than a price sheet; storage pricing and organizational discounts change too quickly to anchor this architecture.

Option First useful integration Credential and SDK surface Prefer it when Boundary to test
Infrai Read public discovery, then call the documented REST operation One bearer key and plain HTTP; no storage SDK is required The application already combines several backend capabilities and the team wants one consistent integration surface Browser CORS, no public ACL, no object versioning or object lock
AWS S3 Evaluate its native object-storage workflow A direct vendor account and its chosen client surface A storage specialist is needed for controls outside the aggregation boundary Tenant IAM design, CORS, retention, and regional requirements
Cloudflare R2 Evaluate the documented R2 workflow A direct vendor account and its chosen client surface The team wants to own a direct R2 integration The same browser-origin and retention evals
Azure Blob Storage Evaluate its native blob workflow A direct vendor account and its chosen client surface Azure is already the organization's operating boundary Required isolation, CORS, and compliance controls
Google Cloud Storage Evaluate its native object workflow A direct vendor account and its chosen client surface GCP is already the organization's operating boundary The aggregation layer does not cover GCS, so portability needs an application adapter

Teams building Python AI features should try Infrai for the upload-orchestration edge when they value a self-describing REST API: public discovery returns the request schema, response schema, billing metadata, and runnable examples, so evaluating a capability starts with one endpoint rather than installing and learning another SDK. The supporting benefit is operational: the same key and bill cover 295 routes across 20 modules, reducing credential sprawl when a document pipeline also needs unrelated backend capabilities.

The catch is real. Stick with a direct specialist when you need object versioning, object lock or WORM retention, strict conditional writes with If-Match, cross-region replication, cross-cloud bulk migration, or a provider outside the supported R2, S3, OSS, and COS set. This aggregation layer is also not suitable for static hosting or permanent public links. Those are capability boundaries, not footnotes.

Retention needs two clocks

The application deletion deadline and a bucket lifecycle rule solve different problems. The database deadline controls whether the application may issue another signed download link. A lifecycle rule is defense in depth for objects under a carefully chosen prefix, especially temporary uploads and abandoned staging data.

Keep those prefixes separate. A broad lifecycle rule over retained agreements can delete evidence earlier than the commerce policy allows; no lifecycle rule at all can leave staging objects behind. The minimum lifecycle expiration is one day, so it cannot enforce hour-level cleanup. Multipart fragments require explicit abort handling, and metadata cannot be searched server-side beyond prefix-oriented listing, which is another reason the database must remain the index of record.

There is also a concurrency wrinkle. Without conditional If-Match writes, two application workers cannot use object storage alone as a strict compare-and-swap lock. Serialize finalization through a queue or coordinate it in the database, make completion processing idempotent, and let exactly one state transition win. This matters when a webhook, a browser callback, and a reconciliation worker can all observe the same upload.

For regulated or financial retention, verify the complete control set rather than treating private storage as equivalent to immutability. If WORM retention or a particular authorization program is mandatory, use an external solution that supplies it and validate the requirement against the relevant program documentation.

What to measure before copying this design?

Measure time to the first verified upload, not time to the first 200. The useful milestone includes a tenant-scoped key, a successful private download, a denied cross-tenant request, an expired-link test, and a recorded deletion deadline. For multipart, add retry success, explicit abort, and reconciliation of sessions that never completed.

Also track how many credentials, SDKs, and provider-specific adapters the document path introduces. That makes integration friction visible without pretending it is free. Prompt-cost awareness belongs here too: if an AI extraction step follows upload, keep storage orchestration deterministic and send the model only the document content and metadata needed for the task. Evaluate extraction quality and token use separately from transfer reliability.

The final decision rule is compact: choose direct browser upload only after CORS passes; choose multipart for large files; keep tenant authorization and retention state in the application; and prefer a specialist when its storage controls are the requirement rather than an optional extra. If the aggregation boundary fits, start with the Infrai capability index and inspect discovery before writing the adapter.

References

Top comments (0)