Stage 7 · Master
File Uploads & Object Storage
Object Storage with S3-Compatible APIs
Attachments live in tenant-scoped object keys, and the API signs access instead of proxying the bytes itself.
Why the API Should Not Be the File Pipe
Once Fieldwork started supporting attachments, the most important architecture choice was not bucket vendor. It was whether the API process itself would relay the bytes. We rejected proxying attachments through Gin for the normal path because it makes the API responsible for the least interesting and most expensive part of the workflow: long-lived byte transfer. Every megabyte passing through the API competes with task reads, auth checks, and background control-plane work. The API should decide whether an upload or download is allowed, not act like a custom CDN.
That made S3-compatible object storage the natural fit. The compatibility layer matters more than Amazon specifically. It lets us target managed S3, MinIO in lower environments, or another provider with one client model. More importantly, it gives us presigned URLs, which are the clean separation between policy and transport. The API authenticates the tenant and object intent, then signs a narrow capability for the client to use directly against storage.
| Design | Short-term convenience | Why Fieldwork rejected or chose it |
|---|---|---|
| Relay uploads/downloads through API | One simple client integration point | Turns the API into a bandwidth bottleneck and complicates autoscaling |
| Direct object-store access with broad credentials | Fast client integration | Credentials scope is too wide and hard to revoke safely |
| Presigned URLs from API | Needs a signing flow | Chosen because policy stays server-side while bytes bypass the app process |
That is the real boundary. Authentication, authorization, and key naming happen in the API. Bulk data movement happens in object storage where it belongs.
Tenant-Scoped Object Keys
Object keys are part of the tenancy model. Fieldwork does not let clients choose arbitrary paths because path choice is one of the easiest places to leak tenant boundaries. Keys are generated server-side using tenant, workspace or task context, a logical attachment ID, and a sanitized filename segment. We rejected flat bucket layouts like attachments/<uuid> because they remove useful locality and make debugging harder. We also rejected user-controlled key prefixes because they invite accidental collisions and authorization bugs when the metadata record and object path drift apart.
- Generate object keys server-side from tenant and domain context.
- Use stable attachment IDs so metadata and object naming stay aligned even if filenames change.
- Keep bucket count low; tenancy is usually a key-prefix concern here, not a bucket-per-tenant concern.
Presigned Uploads and Downloads
Presigned uploads solve the transport problem, but only if the rest of the flow is explicit. Fieldwork first creates an attachment intent in the API: verify the caller can attach to the task, generate the object key, and return a short-lived presigned PUT URL. After the client uploads, it calls back to finalize metadata or the system validates upload completion asynchronously. Downloads follow the same shape in reverse: authorize access to the attachment record in Postgres, then issue a presigned GET for a narrow time window. We rejected permanent public URLs because task attachments inherit workspace permissions. Storage objects are not public assets; they are private tenant resources with temporary capabilities.
Short expiries and narrow object scope matter. A presigned URL is effectively permission embodied as a URL, so treat its lifetime and logging surface accordingly.
Metadata, Access, and Cleanup
The object store only knows keys and bytes. Fieldwork keeps all human and tenant meaning in Postgres: who uploaded the attachment, which task it belongs to, what content type was expected, whether it is quarantined, and whether it has been soft-deleted. That metadata record is the thing the rest of the application queries. Storage lifecycle rules handle old temporary upload objects, and background cleanup removes orphaned objects if finalization fails. We rejected the habit of letting the bucket become the index because bucket listings are a poor substitute for relational queries and access control.
| Concern | Object storage | Postgres metadata |
|---|---|---|
| Bytes | Yes | No |
| Task and tenant relationships | No | Yes |
| Authorization checks | Via short-lived presign only | Yes |
| Cleanup policy | Lifecycle expiry for temp objects | Application-driven soft delete and audit |
The Decision
Fieldwork uses S3-compatible object storage with server-generated tenant-scoped keys and presigned URLs for normal upload and download traffic. We rejected making the API process the permanent byte path because that spends compute and memory on the least valuable part of attachment handling. The API decides who may access which object and for how long; the object store moves the bytes. Postgres keeps the metadata that gives those bytes application meaning. That split is the whole strategy.
Mark this lesson complete to store local progress and unlock a cleaner resume path the next time you visit.