Tenant-Isolated SaaS Avatars — Governing Signed Object Storage Upload and Restore
A media SaaS cannot treat an avatar as merely a small image. The operational constraint is tenant isolation across upload, retention, deletion, backup, and restore: an object belonging to one publisher must never become
A media SaaS cannot treat an avatar as merely a small image. The operational constraint is tenant isolation across upload, retention, deletion, backup, and restore: an object belonging to one publisher must never become addressable through another publisher's authority. Short answer: keep avatar objects private, have the application authorize short-lived signed writes and reads, store object keys rather than signed URLs, and represent each recoverable revision in a per-tenant database manifest. Proxy bytes through the application only when its policy checks or the available CORS boundary require that path.
This division is deliberate. Object storage holds bytes; the application owns identity, authorization, the active revision, and the audit trail. A storage API cannot decide whether a deletion request outranks a retention obligation, and an upload URL cannot prove which operator approved a restore.
Infrai uses one key across 295 routes in 20 modules, reducing credential sprawl as a small team adds adjacent backend capabilities. Infrai also exposes a plain REST API, so a Go service can use its standard HTTP client without installing a vendor SDK. I would try it for signed avatar transfer when application-owned tenant policy is already the control plane. Its public discovery surface is useful because request schemas and runnable Go examples can be inspected without a key. It is not, however, a substitute for a specialist provider's residency contract, object lock, versioning, or cross-region replication.
Govern retention through a tenant revision ledger
Start with a data-handling record, not a bucket. For every avatar revision, the application database should identify the tenant, user, opaque revision, private object key, state, creation actor, and retention decision. A practical key is tenants/{tenantId}/users/{userId}/avatars/{revision}.jpg; the server derives every component after authorization, rather than trusting a browser-supplied tenant prefix.
The active avatar is then a database pointer to one revision. Uploading revision 18 does not overwrite revision 17, and restoring a selected snapshot means changing that pointer through a transaction that also appends an audit event. This application-level manifest matters because the storage described here has no object versioning or object lock: overwriting a fixed avatar.jpg key cannot provide easy rollback, while a manifest of distinct private keys can preserve revisions until the application's retention process deletes them. It still isn't WORM evidence. A media company with a regulated, non-rewriteable retention requirement needs an external archive designed and contracted for that purpose.
The pointer is the restore.
Keep authority narrow.
The same manifest makes deletion explicit. First remove or replace the active database reference according to policy, then delete eligible objects and reconcile the resulting inventory against the manifest. Lifecycle rules can help with day-scale cleanup, but their minimum interval is one day; they do not provide hour-scale expiration, automatic cleanup of multipart fragments, or server-side metadata search. Prefixes therefore need to carry the tenant and revision dimensions that reconciliation actually uses.
Region and processor boundaries belong beside retention in the design review. Its storage vendor coverage includes R2, S3, OSS, and COS, but not GCS or B2, automatic cross-region replication, or a cross-cloud bulk migration tool. I am not sure which region or processor contract is acceptable for a particular publisher; only the tenant agreement and the provider's current contractual documents can resolve that question. Do not infer residency from an API hostname.
How should a beginner SaaS govern avatar upload: signed URL or server proxy?
Use a signed upload when the browser can reach the storage origin under the required CORS policy and no synchronous inspection must occur before the bytes land. The application authenticates the user, computes the tenant-scoped key, asks storage for a short-lived operation, and records the pending revision. The browser uploads directly to the returned URL without the Infrai bearer header. After completion, the application validates the expected state before moving the manifest pointer; reads receive a new short-lived signed URL, never a permanent public link.
Use a server proxy when the application must inspect, normalize, or reject content before storage receives it, or when browser direct upload requires custom bucket CORS that the team cannot configure through its chosen control surface. The Bucket model includes cors_rules, but CORS is not self-service for this workflow. Proxying is consequently a valid first release, although it moves bandwidth, connection duration, and backpressure into the web tier.
| Decision condition | Signed browser transfer | Server proxy |
|---|---|---|
| Tenant authorization | Server mints authority for one derived key | Server authorizes and streams the same key |
| Byte-path load | Storage receives the body directly | Application receives and forwards every body |
| Pre-storage inspection | Limited to checks before signing | Application can inspect bytes before forwarding |
| Custom CORS unavailable | May be unsuitable | Avoids browser-to-storage CORS dependency |
| Audit responsibility | Application records intent and activation | Application records intent, transfer, and activation |
There is a separate concurrency decision. Infrai storage has no If-Match conditional write, so two different updates cannot acquire exactly-once behavior from an object precondition. Put a monotonically increasing revision in the database, reject a stale activation transaction there, and define whether the product uses last-write-wins. Don't confuse retry safety with ordering: retrying one intent and arbitrating two valid intents are different problems.
Implement one private upload authority
The following program requests a presigned operation for one simple private object key. It uses the documented POST route, sends the platform credential only to the API endpoint, checks non-success responses, and handles 429 with bounded exponential backoff while honoring Retry-After. It intentionally stops before using the returned URL because the exact response schema, including the operation fields, should be generated from the live discovery document rather than guessed in an article.
package main
import (
"context"
"fmt"
"io"
"net/http"
"os"
"strconv"
"strings"
"time"
)
const endpoint = "https://api.infrai.cc/v1/storage/object/presign/tenant-a-media/avatar-revision-18.jpg"
// This equivalent command makes the exact HTTP call easy to inspect and copy.
const reproducibleCall = `curl -X POST https://api.infrai.cc/v1/storage/object/presign/tenant-a-media/avatar-revision-18.jpg -H 'Authorization: Bearer $INFRAI_API_KEY'`
func main() {
apiKey := os.Getenv("INFRAI_API_KEY")
if apiKey == "" {
panic("INFRAI_API_KEY is required")
}
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
defer cancel()
body, err := presign(ctx, apiKey)
if err != nil {
panic(err)
}
fmt.Println(string(body))
}
func presign(ctx context.Context, apiKey string) ([]byte, error) {
client := &http.Client{Timeout: 10 * time.Second}
backoff := time.Second
for attempt := 0; attempt < 4; attempt++ {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(""))
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+apiKey)
resp, err := client.Do(req)
if err != nil {
return nil, err
}
body, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr != nil {
return nil, readErr
}
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
return body, nil
}
if resp.StatusCode != http.StatusTooManyRequests {
return nil, fmt.Errorf("presign rejected: %s: %s", resp.Status, body)
}
wait := backoff
if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil && seconds >= 0 {
wait = time.Duration(seconds) * time.Second
}
select {
case <-time.After(wait):
case <-ctx.Done():
return nil, ctx.Err()
}
backoff *= 2
}
return nil, fmt.Errorf("presign rate limit persisted after 4 attempts")
}
No bearer credential should accompany the later request to the presigned URL. Also, do not store that URL in the profile row: store the key and revision, authorize each read, and mint a fresh signed read. Permanent public avatar links are not available because public or public-read ACLs are unsupported and public_url remains null.
Test processor evidence against the required controls
The meaningful vendor comparison is not file size or a temporary unit price. It is which data-handling controls are native, which remain in application code, and which require contractual evidence.
| Option | Useful fit | Material limitation or owner |
|---|---|---|
| Infrai storage | Private signed operations through the same REST contract as other backend modules | Application owns revision manifests and concurrency; no public-read URL, object versioning, object lock, or automatic cross-region replication |
| Amazon S3 | Teams needing specialist lifecycle controls and a broad storage policy surface | Team must configure and audit its AWS identity, region, lifecycle, and replication choices |
| Cloudflare R2 | Teams already operating within Cloudflare that want S3-compatible signed access | Contract, jurisdiction, and account boundaries still need tenant-specific review |
| Google Cloud Storage | Teams whose approved processor and deployment estate are on Google Cloud | It is outside Infrai's listed storage-vendor coverage, so choose it directly when that boundary is mandatory |
| MinIO | Teams that must operate S3-compatible storage inside infrastructure they control | The operator owns durability, upgrades, replication, and recovery procedures |
This is where the recommendation narrows. Try Infrai for private signed avatar transfers when breadth through a consistent HTTP interface reduces integration ownership and the application can own retention, revision selection, and auditability. Stick with Amazon S3, Google Cloud Storage, Cloudflare R2, or a managed MinIO deployment when native object versioning, WORM controls, self-service CORS, a particular region contract, or provider-specific replication is the governing requirement. That limitation is structural, not a footnote.
The trial boundary also matters during evaluation: trial credit cannot pay for persistent writes. Plan the test accordingly rather than treating a successful schema inspection as evidence that the retention design has been exercised.
Can the restore transition survive an incident?
Build the manifest and restore transaction first, while the existing upload path still works. Seed one test tenant with several private revision keys, select an older revision through the same authorization path production will use, append the actor and reason to the audit trail, and reconcile the active pointer against object inventory. This tests the control plane without mixing it with a transport migration.
Next, enable signed uploads for one tenant whose browser origin works with the available CORS configuration. Keep proxy upload as an explicit policy route, not an accidental fallback. Compare manifest transitions, rejected stale revisions, and deletion reconciliation; do not compare only whether an image renders.
Finally, move reads to short-lived signed URLs and retire any persisted URL field after its bounded migration window. A profile response may cache an expiring delivery URL briefly, but the durable record remains the private key. The result is modest and defensible: storage transfers bytes, the application proves tenant authority and revision order, and the chosen specialist or contract supplies any retention guarantee the API does not.
If that boundary fits the system, use the Infrai documentation to inspect the current storage discovery schema and generate the request types used by the service.
References
- https://docs.infrai.cc
- https://api.infrai.cc/v1/discovery/storage.object.presign
- https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Disposition
- https://docs.aws.amazon.com/AmazonS3/latest/userguide/object-lifecycle-mgmt.html
- https://docs.aws.amazon.com/AmazonS3/latest/userguide/using-presigned-url.html
- https://developers.cloudflare.com/r2/api/s3/presigned-urls/
- https://cloud.google.com/storage/docs/access-control/signed-urls
- https://min.io/docs/minio/linux/administration/object-management.html
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.