D86 — Documents vault direct-to-object-storage transfer: app-signed issue/confirm envelope around presigned S3 URLs (RESOLVED, extends D43)
Architecture decision record, relocated verbatim from the retired single-file
docs/tenancy/decision log. Status, thematic clusters, and how to record a new ADR: the decision log index.
The vault's proxied upload (D43) pushes every byte through the app (POST /api/v1/documents →
encrypt → local/private disk), which caps practical file size at the PHP/proxy request envelope and
makes the app a bandwidth middleman. Decision: an opt-in direct-transfer flow where the app
issues short-lived presigned URLs and owns the metadata/lifecycle, and the BYTES move directly
between the client and an S3-compatible bucket — config-gated OFF by default
(documents.direct_transfer.enabled; both endpoints refuse 409 DIRECT_TRANSFER_DISABLED, and the
flag is a kill switch for the whole surface including the confirm). Shape:
- Initiate (
POST /documents/direct-uploads→InitiateDirectDocumentUploadCommand, the samedocuments.uploadpermission as the proxied store): creates the metadata row via the bus in the tenant's RLS scope — statusawaiting_upload(a new pre-byteDocumentStatus), a server-derived object key (direct/<uuid>.bin; never client-chosen, never exposed — the URL embeds it),storage_encryption = server_side(D87), no mime/sha256 yet (both nullable until the bytes land) — and returns a presigned PUT (documents.direct_transfer.upload_url_ttl, 300s) minted through theDirectTransferUrlSignerseam (tests bind a fake; the real signer wraps the S3 driver'stemporaryUploadUrl, proven against MinIO per D82). - Confirm (
POST /documents/{uuid}/direct-uploads/complete→CompleteDirectDocumentUploadCommand, idempotent): verifies the object LANDED at the upload key (missing ⇒ 409DIRECT_UPLOAD_INCOMPLETE, row stays awaiting), then server-side COPIES it to a distinct CONFIRMED key (direct/<key>/confirmed.bin— a key NO presigned PUT is ever issued for) and streams THE CONFIRMED COPY once server-side (8 KB chunks) to (a) count the ACTUAL size againstdocuments.max_bytes(aborting early past the cap), (b) sniff the MIME from the leading bytes (the D43 trust-the-bytes rule), and (c) compute the sha256 integrity anchor every read path + the integrity sweep re-verify. The copy is the swap-window (TOCTOU) control: the presigned PUT stays technically writable for its full TTL even after confirm, so verifying and serving the upload key itself would let a client swap the object post-confirm/post-scan and have the swapped bytes served; instead the confirmed copy is what is verified, persisted as the document'spath, and read by every downstream path (serve/forward/scan/sweep) — a post-confirm PUT to the old upload key mutates an inert object. On admit the now-inert upload key is deleted (best-effort, checked + logged). A violation deletes BOTH objects (checked + logged) and refuses 422DOCUMENT_REJECTED(the vault never admits the bytes). On admit:stored, then the normal downstream (D88 quarantine/scan when enabled, else the D43 forward) — dispatchedafterCommit(), preserving store-before-forward. Transaction shape: the command isWithoutTransaction(the LogoutClientCommand opt-out precedent) — streaming a 25 MB object must not run inside a DB transaction holding a row lock, so the handler does all object-storage I/O lock-free and then opens its OWN shortlockForUpdatetransaction to re-assertawaiting_upload(the race arbiter — the loser returns the admitted document idempotently) and flip the state. NOTE the app deliberately reads the bytes ONCE at confirm (bucket→app, not client→app): giving up the sniff + hash would gut the vault's integrity model, and the flow's point is to offload the CLIENT transfer path, not to blind the vault. The synchronousContentPreScannertripwire does NOT run on this path (inert for the binary allowed-mime set anyway); the D88 malware scan is the direct path's content control. - Download / access logging (the load-bearing part):
IssueDocumentDownloadUrlQuerystill mints the ~90s app-signedstreamroute — the signature stays the credential and the route stays the single choke point. For aserver_sidedocument the streaming controller logs the serve todocument_accessesFIRST, then 302-redirects to a short-lived presigned GET (download_url_ttl) minted through the same signer seam. A raw presigned GET handed straight to clients would bypass the per-serve access log (D6) and the DLP egress capture — the redirect preserves both (theEgressesDatavolume reads the stored size for a redirect serve, so egress accounting stays truthful). The presigned GET signs the ORIGINAL filename + sniffed MIME into the S3response-content-disposition/response-content-typeoverrides (RFC 6266, the same escaping as the envelope path'sstreamDownload), so a redirect download presents like a streamed one — never the rawdirect/…confirmed.binkey with the client-set upload content type. A missing object at serve time fails closed asDOCUMENT_INTEGRITY_FAILUREand is loggedintegrity_failed, exactly like a missing envelope blob. The serve does NOT re-hash the whole object per request (that would re-proxy the bytes); the confirm-time sha256 (of the confirmed copy — the only key ever served) is re-verified by the daily integrity sweep and any server-side read. - Size enforcement point: at presign-issue time against the client-DECLARED size (fast
feedback), and authoritatively at CONFIRM time against the actual object — a presigned PUT
cannot carry a
content-length-rangecondition (that is presigned-POST policy machinery, and Laravel'stemporaryUploadUrlpresigns a PutObject with no signed Content-Length). Production should pair this with a bucket lifecycle rule cleaningdirect/objects older than ~1 day (abandoned PUTs) — deploy config, documented in the tracking doc;upload_expires_atis the in-app handle for a future abandoned-row prune.
Deliberately NOT built: a webhook/S3-event-driven auto-confirm (the explicit client confirm keeps the flow provider-agnostic and testable), any client choice of key/bucket, and a public-read bucket posture (the bucket stays private; every GET is presigned + app-logged).