For a B2B SaaS product uploading large media files, put the bytes directly into a private storage bucket, then let a durable event start thumbnailing and virus scanning; make tenant isolation an authorization invariant, not a naming convention. The browser should receive a short-lived upload capability, while the application owns the state transition from untrusted upload to publishable media.
Short answer: issue one scoped upload URL per tenant object, consume an upload notification through an idempotent worker, and publish nothing until validation and scanning have produced a durable verdict.
The decision record: invariants before components
The first invariant is ownership. Every object key, event, processing record, and derived thumbnail must carry a tenant identity that the server derived from the authenticated session. A client-supplied prefix such as tenant-a/ is only a hint; it is not authorization. The server should construct the key and bind that key to the upload record before handing the browser its URL.
The second invariant is quarantine. A successful storage write means only that bytes were accepted. It does not mean the filename is safe, the content type is truthful, or a decoder can parse the file. Keep the original private, apply size and type policy, inspect the content, and make the clean state a deliberate transition. Tiny detail. It matters.
The third invariant is replayability. Notifications may be delayed, duplicated, or acknowledged before downstream work finishes. Use a processing key containing tenant ID, bucket, object key, and an immutable content marker. A retry then resumes a known state instead of producing a second thumbnail, while a replacement upload at the same key becomes a new generation of work.
Here is the boundary I would record in the architecture decision record:
| Boundary | Required behavior | Failure to design for |
|---|---|---|
| Browser to storage | Short-lived capability scoped to one object and one operation | A leaked URL can write outside the intended tenant prefix |
| Storage to event system | Event contains enough identity to look up the upload record | A key-only handler can process an object for the wrong tenant |
| Event to worker | Acknowledge after durable enqueue or durable state change | A crash after acknowledgement loses processing |
| Worker to publication | Scan and media validation gate any readable derivative | A thumbnail can expose unsafe or cross-tenant bytes |
| Application to tenant | Every read checks the stored tenant binding | Prefix filtering alone becomes an isolation bypass |
How should browser storage uploads trigger private-bucket, event-driven processing?
Treat the browser as a byte transporter, not as the workflow coordinator. The API creates an upload record, chooses a server-owned object key, and returns a presigned write capability. After the object lands, the notification consumer verifies that the event matches the pending record and places a compact job on a queue. The worker reads the object, validates it, scans it, writes derived objects under the same tenant boundary, and changes publication state in a transaction.
The critical path can remain small. The details of presigning and notification subscription differ by storage system, so this example keeps those operations behind interfaces rather than pretending that every provider has the same endpoint. That is a feature of the design: provider mechanics are replaceable, authorization rules are not.
from dataclasses import dataclass
from uuid import uuid4
@dataclass(frozen=True)
class UploadGrant:
tenant_id: str
object_key: str
upload_url: str
upload_id: str
def create_upload(actor, filename, content_length, content_type, store, uploads):
if not actor.tenant_id:
raise PermissionError("tenant context is required")
if content_length <= 0 or content_length > 500 * 1024 * 1024:
raise ValueError("file size is outside policy")
if content_type not in {"image/jpeg", "image/png", "video/mp4"}:
raise ValueError("content type is outside policy")
upload_id = str(uuid4())
object_key = f"incoming/{actor.tenant_id}/{upload_id}/original"
uploads.insert_pending(
upload_id=upload_id,
tenant_id=actor.tenant_id,
object_key=object_key,
original_name=filename,
declared_type=content_type,
declared_length=content_length,
)
return UploadGrant(
tenant_id=actor.tenant_id,
object_key=object_key,
upload_url=store.presign_put(object_key, expires_seconds=900),
upload_id=upload_id,
)
def handle_storage_event(event, uploads, jobs):
upload = uploads.find_by_object_key(event.object_key)
if upload is None or upload.tenant_id != event.tenant_id:
raise ValueError("event does not match a pending upload")
dedupe_key = f"{upload.tenant_id}:{event.object_key}:{event.content_marker}"
jobs.enqueue_once("inspect-media", {"upload_id": upload.id}, dedupe_key)
The event's tenant field in this sketch is an internal envelope field, not a claim to trust from the browser. In a real integration, derive tenant identity from the server-side object binding and reject an event that cannot be reconciled with it. Log the upload ID and tenant ID in structured form, but never log the presigned URL or raw media. Alert on events with no matching pending record, repeated scan failures, and work that exceeds its age budget.
I would test the ugly cases first: the tab closes at 99 percent, the event arrives twice, the same key is replaced, the worker dies after the scan but before the database commit, and a tenant tries to read another tenant's object. An HTTP 413 from the API should be a policy decision with a useful message, not a reason to let the browser guess a larger limit. I'm not sure your queue exposes the same replay controls as mine would, so make replay, retention, and dead-letter inspection explicit acceptance criteria.
Which architecture keeps isolation measurable?
There are three common shapes, and the right comparison is operational evidence rather than a feature checklist.
With a shared bucket and server-generated tenant prefixes, isolation is enforced by authorization records and object-key construction. This is usually the least cumbersome shape, but it requires careful tests for list, read, copy, and derived-object paths. A shared bucket is not shared authorization.
A bucket per tenant makes a boundary visible in storage policy and can simplify deletion or retention for a small number of tenants. It becomes awkward as tenant count grows: provisioning, policy updates, event subscriptions, and monitoring all become cardinality problems.
A separate storage account or project per trust domain provides stronger administrative separation. It also multiplies credentials, deployment configuration, and recovery procedures. Use it when regulatory or contractual separation demands it, not because a second prefix feels uncomfortable.
| Shape | Isolation signal | Cost of operating it | Prefer it when |
|---|---|---|---|
| Shared bucket, bound keys | Database binding plus authorization checks | Moderate; tests must cover every object operation | Many ordinary SaaS tenants share the same policy |
| Bucket per tenant | Storage policy boundary | High at large tenant counts | Tenant-specific retention or deletion is central |
| Account or project per trust domain | Administrative boundary | Highest; credentials and recovery multiply | Trust domains have materially different controls |
Measure the choice. A useful dashboard has unauthorized-access denials, orphan objects, unprocessed-event age, duplicate-job rate, scan verdict latency, and derivative writes by tenant. If those metrics cannot answer “which tenant owns this object?” during an incident, the boundary is conceptual rather than operational.
What should be rejected, and when is it still valid?
The tempting design is to upload through the application and call a webhook from the browser after the progress bar reaches 100 percent. It is valid when a synchronous policy decision must happen before storage, when files are small, or when the environment cannot expose a safe direct-upload capability. It is a poor fit for large media in a multi-tenant SaaS path because application bandwidth, request timeouts, deploys, and browser lifecycle become part of the transfer.
The browser callback is still useful for display.
It is not evidence that the event was delivered, that the scan ran, or that a thumbnail is publishable. This distinction becomes consequential when a customer closes a tab after the progress bar reaches 100 percent: the object may exist, the notification may still be waiting in a queue, and the UI may have no authority to declare the media ready. The processing record therefore needs an explicit state machine, with transitions owned by the backend and visible to support staff, rather than a boolean copied from browser JavaScript.
The catch is that direct upload adds eventual consistency in the application workflow. Teams that need a user-visible “ready” state must model it, expose progress from the processing record, and provide a retry or support path for an orphaned upload. Stick with a proxy when the policy requires it; use direct storage when byte volume and application isolation are the dominant constraints.
References
- OWASP File Upload Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/File_Upload_Cheat_Sheet.html
- MDN CORS guide: https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CORS
- Amazon S3 event notifications: https://docs.aws.amazon.com/AmazonS3/latest/userguide/EventNotifications.html
- DigitalOcean Spaces documentation: https://docs.digitalocean.com/products/spaces/
Top comments (0)