Tenant-Isolated Presigned Browser Uploads for Private File Storage (With Signed Restores)

2026-11-10来源 dev.toEnglish

Short answer: let a Next.js server action or API route authorize a tenant-scoped object key and issue a presigned upload URL, send the file from the browser straight to private storage, and issue a short-lived signed read only when that tenant restores a selected snapshot. Keep the database as the catalog and concurrency authority.

Picture the page: a media customer selects yesterday's backup, but the restore record points at an object key that a later upload reused. The storage request itself may have succeeded. The operational failure happened earlier, when key allocation allowed an overwrite that cannot be reversed because object versioning is unavailable.

The useful alert isn't merely "upload failed." It is "a write attempted to reuse an allocated snapshot key" or "a completed upload has no matching catalog record." That distinction drives the design.

Security: How can a Next.js server action presign a private browser storage upload?

Treat the server action as an authorization and naming boundary, not as a file proxy. The browser first asks the application for an upload session. The application authenticates the user, resolves the tenant, creates a collision-resistant key, and calls POST /v1/storage/object/presign/{bucket}/{key}. The browser then uploads the bytes to the returned URL without attaching the platform Authorization header. After completion, the application can verify the object and persist a searchable file record.

The sequence is short:

  1. The browser sends the intended filename and content attributes to Next.js.
  2. Next.js authorizes the tenant and allocates tenants/{tenantId}/backups/{uuid}-{filename}.
  3. Next.js requests the presigned write and returns only the signed URL plus the application-generated object identity.
  4. The browser uploads directly to storage.
  5. The browser reports completion; the application checks the object and commits the snapshot record.
  6. A later restore request is authorized again and receives a short-lived signed read.

Do not pass a user-supplied key through unchanged. The tenant ID must come from authenticated server-side context, while the UUID prevents two backups with the same original filename from colliding. Sanitize the display filename as untrusted input, too. A key is an authority decision.

Reliability begins with verifying the object before catalog commit

The server action can call a small Go service after the browser reports completion. This runnable verifier uses the confirmed GET /v1/storage/object/head/{bucket}/{key} route, sets the method explicitly, keeps the bearer key in an environment variable, and handles 429 with Retry-After or exponential backoff. Set INFRAI_BASE_URL to the API's /v1 base in deployment; keeping it in configuration also makes accidental credential forwarding to a presigned host less likely.

package main import ( "context" "fmt" "io" "net/http" "net/url" "os" "strconv" "strings" "time" ) func main() { if len(os.Args) != 3 { panic("usage: verifier BUCKET OBJECT_KEY") } if err := verify(context.Background(), os.Args[1], os.Args[2]); err != nil { panic(err) } } func verify(ctx context.Context, bucket, key string) error { base := strings.TrimRight(os.Getenv("INFRAI_BASE_URL"), "/") token := os.Getenv("INFRAI_API_KEY") if base == "" || token == "" { return fmt.Errorf("INFRAI_BASE_URL and INFRAI_API_KEY are required") } path := base + "/storage/object/head/" + url.PathEscape(bucket) + "/" + escapeKey(key) client := &http.Client{Timeout: 15 * time.Second} for attempt := 0; attempt < 4; attempt++ { req, err := http.NewRequestWithContext(ctx, http.MethodGet, path, nil) if err != nil { return err } req.Header.Set("Authorization", "Bearer "+token) resp, err := client.Do(req) if err != nil { return err } body, readErr := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) resp.Body.Close() if readErr != nil { return readErr } if resp.StatusCode >= 200 && resp.StatusCode < 300 { return nil } if resp.StatusCode != http.StatusTooManyRequests || attempt == 3 { return fmt.Errorf("head status %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) } time.Sleep(retryDelay(resp.Header.Get("Retry-After"), attempt)) } return fmt.Errorf("retry limit reached") } func escapeKey(key string) string { parts := strings.Split(key, "/") for i := range parts { parts[i] = url.PathEscape(parts[i]) } return strings.Join(parts, "/") } func retryDelay(header string, attempt int) time.Duration { if seconds, err := strconv.Atoi(header); err == nil && seconds >= 0 { return time.Duration(seconds) * time.Second } return time.Second * time.Duration(1<<attempt) } 

The presign operation's request schema should come from discovery rather than a guessed body. Retries for any creating or mutating platform call need an idempotency key. Browser upload retries should retain the same allocated object identity instead of minting a fresh snapshot record on every click.

Forensics starts at the restore page and walks backward

The first response to the hypothetical page is to trace one immutable application identity: tenant_id, snapshot_id, object_key, and the request ID from each platform response. Begin at the denied or missing restore, look up the snapshot in the database, check the object's current state, then walk backward to the upload-session decision. This works because the database is the searchable catalog. Storage metadata may be attached after upload, but object listing can filter only by prefix and metadata itself is not server-side searchable.

Now ask the uncomfortable question: could another tenant ever name this object? A shared bucket with tenant prefixes can be reasonable, but only if every minting path derives the prefix from authenticated context and every read repeats the ownership check. Separate buckets create a harder isolation boundary, at the cost of more lifecycle and policy administration. The right choice depends on the application's tenant count and control model; I'm not sure there is one universal answer. What should never vary is the invariant that the browser cannot choose another tenant's namespace.

Instrument the decision before the transfer. Record the application request ID, tenant ID, snapshot ID, generated object key, operation type, and state transition, but never record the signed URL because its query string is a temporary credential. Count rejected cross-tenant requests and duplicate key allocations. Track sessions that were authorized but never confirmed separately from objects confirmed without a committed catalog row. Those signals point to action: deny an ownership violation, reconcile an orphan, or investigate a missing completion callback.

This is the part teams often skip — and it is the part that makes an overnight page diagnosable.

A 429 is different from a tenant-isolation denial. Honor Retry-After when present and use exponential backoff for rate limits. A repeat completion must remain idempotent at the database boundary.

Portability comes after tenant policy is fixed

AWS S3, Cloudflare R2, Alibaba Cloud OSS, and Tencent Cloud COS are real direct-provider choices. Google Cloud Storage and Backblaze B2 are also established options, but they are outside Infrai's stated vendor coverage. The decision is less about an S3-compatible label than about who owns policy, credentials, CORS, cataloging, and recovery.

Option Useful fit for this workflow Tenant-isolation trade-off
AWS S3 directly Teams already operating AWS storage policy and billing The application still binds signed keys to authenticated tenants
Cloudflare R2 directly Teams deliberately standardized on R2 Provider-specific setup stays in the application boundary
Alibaba OSS or Tencent COS directly Deployments committed to those control planes Each integration carries its own credentials and operations
Infrai over R2, S3, OSS, or COS Teams adding storage beside other backend modules through one REST contract One credential reduces integration sprawl, but the app still owns tenant authorization
Google Cloud Storage or Backblaze B2 directly A hard requirement for GCS or B2 Those vendors aren't covered by the abstraction

Infrai's credible advantage here is breadth behind plain HTTP: live discovery describes 295 routes across 20 modules, so storage can sit beside other backend capabilities without installing another SDK or managing another credential family. Its public, no-key discovery supplies full request and response schemas and runnable examples in 10 languages. That lets an SRE inspect the contract before deployment and lets different runtimes use the same REST conventions. One key and one bill support the workflow, but they don't remove the application's authorization duties.

Stick with direct AWS S3, R2, OSS, or COS when native policy control, provider-specific facilities, or an existing operational investment matters more than a common API. Choose GCS or B2 directly when either is a hard requirement. Infrai is also not suitable for permanent public links or static-site hosting: buckets remain private and delivery uses signed reads.

Governance makes overwrite recovery a database decision

Object versioning and object lock are unavailable, so overwriting a known key destroys the previous value. For a media backup product, the safer rule is append-only naming at the application layer: every snapshot gets a new UUID-bearing key, and a database pointer marks which snapshot is current. Restoring an older snapshot means selecting its stored key and minting a signed read; it does not mean asking storage to recover an overwritten generation.

This also defines deletion. Remove the catalog pointer only under an explicit retention operation, coordinate the object deletion, and keep the transition auditable in the application. If financial-grade WORM retention or provider-managed immutable generations is required, this design is the wrong tool; select a storage service and governance setup that supplies those controls.

There are other boundaries worth putting in the runbook. Lifecycle expiry has a minimum of one day, not hourly granularity. Multipart fragments have no automatic cleanup rule. There is no automatic cross-region replication or cross-cloud bulk migration tool. Trial credit cannot fund persistent writes. None prevents the direct-upload pattern, but each changes the operational work around it.

CORS deserves an explicit preflight check before rollout. The current surface lists a bucket CORS operation, yet the application should still test the exact production origin, method, and headers instead of discovering a mismatch from browser reports. Keep that test in deployment validation, alongside a signed upload, an object check, and a signed restore under the same tenant.

False positives are an operating cost

The earlier signal is a violated application invariant, not a generic storage error rate. Alert immediately on confirmed cross-tenant authorization attempts. Page on sustained inability to mint or complete uploads only when it threatens the backup objective; route abandoned sessions and reconciliation drift to a ticket unless they cross a documented threshold. There is no honest universal number. Your traffic baseline, retry policy, and recovery objective should set it.

Too sensitive, and normal tab closures or client retries wake someone who has no action to take. Too loose, and the first useful signal arrives when a customer asks for a snapshot that the catalog cannot prove exists. Start with dashboards and logs, observe the normal distribution, then promote only the condition whose runbook has a concrete owner and response.

Keep the page narrow.

The final acceptance test is a tenant-isolation matrix: tenant A can upload and restore A's newly allocated key; tenant B cannot mint a read or write for it; a repeated completion does not create a second snapshot; a reused upload ID is rejected by the database; and no response or log exposes a signed URL after its intended handoff. That test says more about backup safety than a successful 200 alone.

References

原文:https://www.a8q9.cn/en/article/tenant-isolated-presigned-browser-uploads-for-private-file-storage-with-signed-r-cd4ce5.html

相关文章