DEV Community

Hwpgsd503817
Hwpgsd503817

Posted on

Private Storage Pattern: Tenant-Scoped User Documents in PDF, DOCX, and Invoices

Short answer: use a private object store for the bytes, a database row as the authorization and lifecycle record, and a tenant-scoped key as an operational index rather than as a security boundary. For a B2B SaaS that stores PDFs, DOCX files, and invoices, this keeps the normal download path indexed and makes tenant isolation testable.

In the upload scenario I'm designing, a customer sends a large document without making the application proxy the bytes. I'd make the application create a pending record first, let the client transfer to a private object, and publish the document only after the application verifies the resulting object and its expected ownership. My review checklist has a simple failure test: if a lookup can return a document without filtering by tenant_id, the design's already wrong, even if the object key contains a convincing prefix.

That is the invariant. A folder-shaped name is useful for operators; it is not an authorization policy.

The governance record is the product boundary

Treat the database as the catalog and the object store as the durable byte layer. A document row should contain an immutable document_id, tenant_id, the owning user_id, the object key, original filename, declared MIME type, size, a checksum where the upload path can verify one, and a state such as pending, ready, or rejected. The row may also carry retention and access policy fields, because those are application decisions rather than properties inferred from a filename.

The object key should be generated from IDs that the application owns. For example:

package main

import (
    "fmt"
    "net/url"
    "strings"
)

func documentKey(tenantID, userID, documentID, filename string) (string, error) {
    parts := []string{tenantID, userID, documentID, filename}
    for _, part := range parts {
        if strings.TrimSpace(part) == "" || strings.Contains(part, "/") {
            return "", fmt.Errorf("invalid document key component")
        }
    }

    return fmt.Sprintf("tenants/%s/users/%s/documents/%s/%s",
        url.PathEscape(tenantID),
        url.PathEscape(userID),
        url.PathEscape(documentID),
        url.PathEscape(filename)), nil
}
Enter fullscreen mode Exit fullscreen mode

The original filename is useful at the tail for diagnosis, but it can't be the identity: users rename files, two users submit invoice.pdf, and filenames can contain characters with path semantics. The immutable IDs do the authorization work in the database query; the prefix makes a bounded reconciliation or tenant export possible.

On download, resolve the document by both tenant_id and document_id, apply the product's ownership or sharing rule, verify that the row is ready, and then request the private object by the stored key. Never accept a client-supplied key as the lookup authority. On upload, the state transition should be explicit: create pending, transfer bytes, verify the object belongs to the row, then change it to ready in a transaction. A retry should reuse the logical document ID or create a clearly separate attempt, rather than silently replacing another document.

The browser-facing response also needs an explicit cache policy. Private documents should not accidentally become reusable browser or intermediary cache entries; Cache-Control exists precisely to express that policy, and the policy belongs to the response path that knows the document's sensitivity. Upload progress is a user-interface concern, not evidence that the catalog transition succeeded. XMLHttpRequest, for example, exposes upload progress events, but a progress bar reaching 100% does not mean the database has published the document.

What does a tenant-isolation test actually prove?

The first failure is an authorization shortcut: deriving access from tenant-a/ and assuming the storage service will enforce the tenant boundary. Prefixes are strings. They can help partition list operations, but they don't replace a tenant-scoped database query, an authorization check, or a private bucket policy.

The second is a split-brain status. The object exists, but the row is still pending; or the row says ready, but the object was deleted. These aren't exotic states because the database and object store don't share one transaction. Design for them visibly. A reconciliation worker can list a bounded tenant prefix, compare the result with rows in non-terminal states, and emit an operator-visible mismatch. It shouldn't run inside a customer download request.

The third is treating object listing as search. “Find this tenant's unpaid invoice” is a metadata query, not a bucket query. Store invoice identity, status, content type, and business timestamps in indexed columns. Use object listings for repair, inventory, and audits, with page limits and a schedule that has its own capacity budget.

The fourth is an unbounded upload. A content type supplied by a browser is a hint, not a complete validation result. Establish maximum size, allowed document classes, timeout behavior, and malware or content inspection policy before the object becomes ready. The exact inspection implementation depends on the product's threat model, so I am not going to pretend one universal scanner or threshold is correct.

The fifth is a retry that creates a new row for every network interruption. That inflates storage, confuses billing, and makes an innocent user action look like a data leak during support investigation. Use an idempotency key or a stable document ID, record attempts separately when needed, and define cleanup for abandoned pending rows and their objects.

Small details become incident boundaries.

How can upload-first storage protect tenant isolation for PDFs, DOCX files, and invoices?

The useful sequence is short enough to put in an integration test:

  1. Authenticate the actor and establish the active tenant from server-side context.
  2. Insert a pending document row with a generated ID and a key derived from server-owned IDs.
  3. Issue a narrowly scoped upload capability for that one object, or perform the transfer through a trusted storage component.
  4. Confirm size, expected media type, checksum or inspection result, and object ownership.
  5. Transition the row to ready only after those checks pass.
  6. On retrieval, query by tenant and document ID, authorize the actor, and serve a private response with an intentional cache policy.

The important property is the ordering. A client can know an upload URL without gaining the ability to list a bucket or read a neighboring tenant's object. The application never needs to accept arbitrary object paths from the browser. That sharply limits the input surface for path traversal, confused-deputy behavior, and cross-tenant lookup mistakes.

Here is the shape of the state transition in Go. The storage client is deliberately an interface: the business rule should be testable without binding the test suite to a provider SDK.

package documents

import "context"

type Document struct {
    ID       string
    TenantID string
    ObjectKey string
    State    string
}

type Catalog interface {
    FindForTenant(ctx context.Context, tenantID, documentID string) (Document, error)
    MarkReady(ctx context.Context, tenantID, documentID string) error
}

type ObjectStore interface {
    Exists(ctx context.Context, key string) (bool, error)
}

func Publish(ctx context.Context, catalog Catalog, store ObjectStore, tenantID, documentID string) error {
    doc, err := catalog.FindForTenant(ctx, tenantID, documentID)
    if err != nil {
        return err
    }

    exists, err := store.Exists(ctx, doc.ObjectKey)
    if err != nil {
        return err
    }
    if !exists || doc.State != "pending" {
        return nil
    }

    return catalog.MarkReady(ctx, tenantID, documentID)
}
Enter fullscreen mode Exit fullscreen mode

The tenantID argument is not decoration; it must participate in the catalog query and in the state transition's conditional update. In a real implementation, the transition would also enforce the expected row version or equivalent concurrency rule. My mileage may vary on the inspection sequence, because regulated invoices and ordinary user attachments do not have the same retention or threat model, but the ownership check should not vary.

Where should the SLO boundary stop?

The buy-versus-build question is less about a feature checklist than about the controls the platform team can operate and test. Keep the comparison tied to the workload:

Pattern Good fit Cost or operational trade-off Reconsider it when
Direct object-storage integration The team needs provider-native retention, replication, versioning, or legal-hold controls More credentials, provider-specific code, and separate operational surfaces The team cannot staff those integrations or needs one control plane
A self-hosted S3-compatible layer The organization needs placement control and accepts storage operations Capacity, durability, upgrades, and incident response become internal responsibilities The on-call budget cannot support storage administration
Managed storage behind a small internal adapter The product wants a stable application contract and delegated durability The adapter can hide important provider limits unless those limits are documented and tested Workloads require controls the adapter cannot expose

For the common B2B document path, I would measure three SLOs separately: time from upload completion to ready, successful authorized retrievals, and reconciliation age for storage/catalog mismatches. A single “upload success” counter is too weak. It can be green while every new invoice remains invisible in the application.

Capacity planning should include database index growth, object bytes, request rate, reconciliation scan volume, and abandoned-upload cleanup. Tenant isolation also has a noisy-neighbor dimension: one tenant's listing or export must have a page budget and concurrency limit so it cannot consume the worker pool needed by ordinary downloads. Set those limits before a large enterprise tenant arrives, not during the first support escalation.

The pattern is not suitable when public distribution, immutable legal retention, object versioning, strict concurrent-writer exclusion, or cross-region disaster recovery is a hard requirement and the selected storage layer cannot provide that control. Stick with a provider-native or specialized system when the missing guarantee is part of the contract. A database ledger and private bucket are a strong default for user documents; they are not a substitute for a records-management system.

References

Top comments (0)