Short answer: for a multi-tenant SaaS that stores user PDFs, DOCX files, and invoices, use one private bucket with generated object keys, then make a tenant-scoped database record the authority for access, metadata, retention, and processing state. Folder prefixes help operations; they are not an authorization system.
That choice puts the difficult question in the right place. The bucket retains bytes. The database answers who owns a document, what the user called it, whether it is still being scanned, and when its retention period ends. Delivery stays simple because the application can authorize a request and issue a short-lived URL for one exact object, without making the object key carry the whole security model.
What should a multi-tenant SaaS store for private PDF, DOCX, and invoice files?
Begin with a document identity, not a filename. A filename is user input and is allowed to be duplicated, renamed, or awkwardly encoded. A document ID is service-owned and stable. The object key can then be generated from the tenant, user, document ID, and a normalized display name:
tenant_42/user_17/doc_01J9Y3/invoice-1048.pdf
The first three segments are identity. The last segment is a convenience for people reading logs or storage listings. If an operator changes the display name, the database can change without copying a large object just to make a virtual folder look tidy. Keep the complete key in the database, because reconstructing it later from mutable fields is an avoidable source of drift.
The bucket should be private. The service checks the authenticated user's tenant and document permissions before it creates a short-lived download URL. An unguessable key is useful as a locator, but it isn't proof that a caller is allowed to read the bytes. That separation is the access-control decision; the prefix is only an organizational decision.
The folder is virtual.
Prefix listing remains useful for bounded repair work. Listing tenant_42/ can scope a reconciliation job, while listing tenant_42/user_17/ can narrow an investigation. Neither operation gives a tenant permission to read another tenant's objects. Enforce that rule with application authorization and database constraints, then test it with deliberately similar IDs such as tenant_4 and tenant_42 so a string-prefix shortcut cannot become a data leak.
Why does database metadata matter more than bucket prefixes?
An admin screen asks relational questions: show unpaid invoices for one tenant, find all DOCX uploads still awaiting processing, or list the documents a particular user submitted last month. A bucket prefix can organize keys, but it is a poor replacement for indexes, status transitions, and ownership constraints.
At minimum, the document row should contain document_id, tenant_id, user_id, object_key, original_filename, mime_type, status, and the retention deadline. Add timestamps and a deletion marker if audit or recovery rules require them. I'm not sure which audit fields your compliance team will require until its retention policy is written down, but the authority should be singular: the database describes the business object, and object storage holds its content.
There is no atomic commit across the database and the bucket in this workflow. Design for that fact. A row can be pending_upload before a client completes the transfer; an object can exist while the final status update is retried. A reconciliation job should find both directions of mismatch: database rows without objects and objects without database rows under application-owned prefixes. A state machine makes that work explicit instead of hiding it behind an uploaded Boolean.
Here is the small part worth making deterministic: key construction and the initial ledger row. The storage adapter should handle provider-specific upload and URL operations behind this boundary.
import mimetypes
import re
import sqlite3
import uuid
from pathlib import Path
SAFE_SEGMENT = re.compile(r"[^A-Za-z0-9._-]+")
def safe_segment(value: str) -> str:
segment = SAFE_SEGMENT.sub("-", value.strip()).strip(".-")
if not segment:
raise ValueError("object-key segment is empty")
return segment
def create_document(
database: sqlite3.Connection,
tenant_id: str,
user_id: str,
filename: str,
) -> tuple[str, str]:
document_id = f"doc_{uuid.uuid4().hex}"
display_name = Path(filename).name
mime_type = mimetypes.guess_type(display_name)[0] or "application/octet-stream"
object_key = "/".join(
[
safe_segment(tenant_id),
safe_segment(user_id),
document_id,
safe_segment(display_name),
]
)
database.execute(
"""
INSERT INTO documents (
document_id, tenant_id, user_id, object_key,
original_filename, mime_type, status
) VALUES (?, ?, ?, ?, ?, ?, ?)
""",
(
document_id,
tenant_id,
user_id,
object_key,
display_name,
mime_type,
"pending_upload",
),
)
database.commit()
return document_id, object_key
The adapter then needs a narrow contract: put, head, create a time-limited download, list by prefix, and delete. Business code should not know the provider's authentication headers or response shape. It should know that a document has an owner, a lifecycle, and an object locator.
How can access control, delivery simplicity, and retention coexist?
Treat them as separate checks in one request path. First load the document by document_id and the caller's tenant_id; then verify the user's permission; then confirm the status permits delivery; only then ask the adapter for a URL. Do not accept an arbitrary bucket key from the browser as the lookup key for authorization. The browser can upload or request a document, but the service remains responsible for resolving identity.
Retention belongs in the ledger too. Store a policy or an explicit expiration timestamp with the document, and have a worker select eligible rows, verify the object reference, delete the content, and record the outcome. HTTP caching is another boundary worth making deliberate: private responses should receive cache directives appropriate to the sensitivity of the document, rather than inheriting a browser or proxy default. MDN's Cache-Control reference is a useful reminder that delivery headers affect shared caches, not just application code.
Upload UX is part of the architecture. A browser may need progress reporting, cancellation, and a retry path; the XMLHttpRequest documentation describes upload progress events, but progress is not evidence that the database state is available. Mark the object available only after the server has confirmed the transfer and the document has passed the checks your workflow requires. Keep those transitions observable.
One ugly case deserves more attention than a polished happy path: a client retries after the network drops just after the object is stored. Without an idempotency key or a uniqueness rule tied to the intended submission, the retry may create two ledger rows and two invoice objects. I would rather see a duplicate rejected with a clear 409 than silently lose the relationship between a payment record and its source document. The precise retry policy is product-specific, but the duplicate state must be visible.
Which storage trade-offs should the document architecture make?
There is no universally best bucket layout. The useful comparison is between failure modes and operational ownership:
| Decision | Strength | Cost or limit |
|---|---|---|
| One private bucket with tenant prefixes | Central policy and a simple adapter; bounded prefix scans are easy to reason about | Isolation still depends on application authorization and correct database queries |
| One bucket per tenant | A visible boundary can help teams with unusually strict administrative separation | Bucket policy and lifecycle management multiply as tenants grow |
| Filename-derived keys | Human-readable keys are easy to recognize at a glance | Renames, collisions, unsafe characters, and user-controlled paths become correctness problems |
| Generated ID plus display name | Stable identity and useful operational context without trusting the name | Operators need the database to interpret a key |
| Direct browser upload | Less application bandwidth and a responsive upload experience | More token, expiry, validation, and retry states to operate |
| Application-mediated upload | One place to validate and record the transition | The application carries more transfer load |
The recommendation in this table is conditional. For a normal multi-tenant document workflow, the generated-ID pattern in one private bucket is a reasonable default. It is not suitable when a tenant must have a separately administered storage account, when anonymous permanent links are the product, or when the retention requirement needs immutable, provider-enforced records that this application-level ledger cannot provide. In those cases, choose separate tenancy boundaries or a storage system with the required governance controls.
The same caution applies to the database. It should be canonical for application metadata, but it is not a substitute for backups, recovery testing, malware scanning, or an audit log. Those are adjacent controls, and omitting them because the key looks well organized is how a tidy design becomes an incident.
How should teams roll out private user document storage?
Ship the contract in a test tenant first. Test cross-tenant reads, guessed keys, renamed files, repeated submissions, interrupted uploads, expired retention, and deletion while processing. Use fixtures that include a PDF, a DOCX file, and an invoice with the same basename; the point is to prove that display names do not define identity.
Then add reconciliation metrics before adding traffic. Track pending rows by age, objects with no ledger row, ledger rows with no object, URL issuance decisions, and deletion outcomes. Alert on trends, not only on a single failed request. A queue can retry a transition, but it cannot decide whether a document belongs to a tenant; that decision stays with the database query and authorization layer.
Keep the first migration narrow: create new records in the ledger, verify them against the private bucket, and move tenants in bounded batches. I've found that a boring migration plan exposes more useful questions than a storage demo does. Who can read an invoice? Which clock defines retention? What happens when a user is deleted? If those answers are not written down, the object key is carrying responsibilities it cannot safely carry.
Top comments (0)