Short answer: keep each tenant's PDF and DOCX backup snapshots in a private object-storage bucket, make the snapshot key immutable, and let the Node.js service authorize a restore before it mints a short-lived signed download URL. Put ownership and restore state in a database; bucket metadata is useful context, not an authorization system.

That decision follows the awkward constraint in logistics: a document may need to be delivered quickly during a shipment dispute, while access must remain scoped to the tenant that owns it. Delivery simplicity is valuable, but it must come after the ownership check. A permanent link reverses that order.

The constraint is tenant isolation, not file upload

Start with a record that has a tenant ID, a document ID, a snapshot ID, an object key, the original filename, the media type, and a lifecycle state such as writing, ready, or deleted. The object store holds bytes. The application database holds the facts needed to decide who may see those bytes.

An object key can make operational boundaries obvious:

from pathlib import Path def snapshot_key(tenant_id: str, document_id: str, snapshot_id: str, filename: str) -> str: suffix = Path(filename).suffix.lower() if suffix not in {".pdf", ".docx"}: raise ValueError("only PDF and DOCX snapshots are accepted") return ( f"tenants/{tenant_id}/documents/{document_id}/" f"snapshots/{snapshot_id}/original{suffix}" ) 

The snapshot ID matters more than the filename. A logistics customer can upload several documents named delivery-note.pdf, and a replacement should not silently change the bytes that an earlier audit refers to. The database pointer can move to a new snapshot after the new object is complete; the old key remains a separately governed retention decision.

The prefix is not proof of authorization. A caller can present a plausible tenant ID, so the service must load the document row using the authenticated identity and compare the tenant relationship before it constructs a download grant. That check belongs in the application path, where it can be logged and tested.

This is the boundary.

How should Node.js handle private object storage, metadata, and signed download URLs?

Treat upload and delivery as two different workflows. On upload, validate the authenticated tenant, accept only the intended media types, create a new snapshot row, write to the generated key, and mark the row ready only after the write is confirmed. On download, authorize the row first, then create a signed URL for that exact key and an intentionally short expiry.

The signed URL is a delivery token. It is not a replacement for a permission model, and it is not a durable document identifier. Never store it in the database as the canonical location: it expires, and anyone who receives it may use it until expiry. The service should return it only after the current request has passed the tenant check.

Here is the decision boundary in a small, SDK-neutral example. The Node.js handler can implement the same contract with its chosen S3-compatible client; the important behavior is the ordering, not a vendor-specific method name.

from dataclasses import dataclass @dataclass(frozen=True) class Snapshot: tenant_id: str document_id: str snapshot_id: str object_key: str state: str def can_issue_download(request_tenant: str, snapshot: Snapshot) -> bool: return ( snapshot.state == "ready" and snapshot.tenant_id == request_tenant ) def download_grant(request_tenant: str, snapshot: Snapshot, presign) -> str: if not can_issue_download(request_tenant, snapshot): raise PermissionError("snapshot is not available to this tenant") return presign(snapshot.object_key, expires_seconds=900) 

The presign function is deliberately an interface. It keeps the authorization decision testable and prevents a storage SDK from becoming the place where tenant policy is hidden. The returned URL should be fetched without forwarding the service's credential. A browser or worker needs only the signed request.

Which failure modes make restore unsafe?

The most damaging mistakes are quiet ones. A public bucket makes delivery easy but turns a copied URL into an access path. A mutable key makes a historical snapshot point at new bytes. Metadata that says tenant_id=acme may be useful for inspection, but it does not replace the database query that establishes whether the current principal belongs to Acme.

There is a less obvious race. If a restore request reads a row while an upload is still in progress, it can issue a grant for an object that is not ready. The state transition must be explicit, and the service should refuse anything except ready. A deletion should invalidate the application row before the cleanup worker removes bytes; this closes the normal request path even while physical deletion is pending. Consider a driver-document workflow in which a dispatcher replaces a scanned delivery note while a claims analyst is already requesting the prior snapshot. If both operations use documents/42/latest.pdf, the analyst may receive the replacement and the audit trail will still name the same key. With immutable snapshot IDs, the analyst's request resolves to the old ready row, the dispatcher writes a new key, and the database changes its current pointer only after the new bytes pass validation. The two requests can still overlap in time, but they no longer compete over the meaning of one object name. That distinction is small in code and large in a post-incident review.

No shortcut.

For each snapshot, test these cases before production: another tenant receives a denial, a missing row receives a denial, a writing row cannot be signed, an expired URL cannot be reused as a fresh grant, and a replacement creates a new snapshot ID. I use a 900-second expiry as a starting policy for an interactive restore, then shorten or lengthen it after reviewing document sensitivity and download time. Your mileage may vary.

The operational signal should identify tenant, document, snapshot, request outcome, and authorization reason without logging the signed URL or the document contents. A 403 in the negative authorization test is useful evidence; it is not a reason to loosen the bucket policy. Keep it boring.

What should a logistics team compare before choosing a bucket layout?

Compare controls and recovery behavior, not just whether an API accepts an upload. An S3-compatible interface can simplify the first integration while leaving important questions unanswered: can snapshots be retained immutably, can old versions be recovered, where are replicas held, how are incomplete multipart uploads cleaned, and can the team export data without rewriting application keys?

Design choice Good fit Trade-off to accept
One private bucket with tenant prefixes A service with one application-level authorization model Prefixes organize objects but do not enforce tenant isolation by themselves
Separate bucket per tenant Strong administrative separation for a small tenant count Provisioning, policy changes, and inventory become more complex
Immutable snapshot keys Audits and restore requests that must name an exact historical state Cleanup and retention need an explicit job and policy
Mutable “latest” key A simple cache or disposable export It is unsafe as the only reference to an auditable backup
Browser-direct signed upload Large files where the application should avoid proxying bytes CORS, completion confirmation, and abandoned-upload cleanup add work

The catch is that access control and delivery simplicity pull in opposite directions. A single private bucket with application checks is often a reasonable starting shape, but it is not suitable when each tenant needs an independent administrative boundary or when a regulator requires storage-level separation. Use separate buckets, accounts, or an approved archival system when that requirement is real. Don't manufacture compliance from a naming convention.

For PDF and DOCX, the extension is a validation hint, not proof of content. Keep the declared media type and original name for presentation, inspect content according to the application's threat model, and treat uploaded documents as untrusted input. Malware scanning, text extraction, and preview generation should write derived objects under new keys; they should not overwrite the source snapshot.

How can the restore rollout stay reversible?

Roll out the data contract before changing delivery. First create snapshot rows and write new objects under immutable keys. Next expose a restore endpoint that checks tenant membership and ready state. Only then hand back signed URLs. This order lets the team compare database records with object inventory before users depend on the new path.

Run a small restore drill with a known PDF and DOCX, then delete the test grant and verify that a new request must pass authorization again. Measure time to issue a grant, time to retrieve a typical document, orphaned writing rows, cleanup lag, and failed restore reasons. I’m not sure a dashboard with only request latency would reveal the real risk; an authorization-denial count and a reconciliation report are more relevant here.

Keep a migration record containing the tenant ID, document ID, snapshot ID, source key, destination key, checksum if the storage contract exposes one, and completion state. If the process stops halfway, resume from that record rather than guessing from filenames. A failed migration must leave the old snapshot usable until the new one has passed verification.

Stick with a simpler server-mediated download when the files are small and the security boundary is easier to reason about there. Choose direct signed delivery for larger documents or higher concurrency, but preserve the same database authorization step. The URL mechanism can change; the ownership model should not.

References