DEV Community

robertmiller4179
robertmiller4179

Posted on

Edtech Storage Isolation — Private User Documents, Prefix Keys, Database Metadata

Short answer: keep PDF, DOCX, invoice, and lesson-media bytes in a private bucket under tenant- and user-scoped prefixes, while a database row remains the authority for ownership, filename, MIME type, and processing status. Give the browser a short-lived presigned upload URL so a 4 GB lecture recording doesn't pass through an application server.

The decision rule is blunt: if an authorization check can't be expressed against the database row before a signed URL is issued, the upload design isn't ready for multi-tenant production. Prefixes make listing practical. They don't establish ownership, and object metadata isn't an admin search index.

For an edtech system, this separates two very different duties. Object storage carries large, opaque bytes. The application database answers questions such as “which school owns this recording?”, “who uploaded the invoice?”, and “is the virus scan complete?” That split is the useful pattern, regardless of provider.

Tenant governance starts in the database

Start with one private bucket and a deterministic key such as tenant_id/user_id/document_id/original-name.pdf. The identifiers provide useful prefix boundaries; the final name helps operators recognize an object during an investigation. Treat the full key as an address, not an access-control policy.

The database row should be created before the browser receives permission to upload. Give it a generated document ID, tenant ID, user ID, object key, original filename, declared MIME type, and a status such as pending_upload. Authorization decisions use the tenant and user columns from that row. After upload, a trusted completion path checks the object and advances the status. Search screens, retention reports, and support tooling query these rows rather than scanning a bucket.

This matters because object listing supports prefix filtering, not metadata queries. A folder-like prefix can efficiently narrow an operational listing to one tenant or user, but it can't answer “show every unpaid invoice uploaded this week.” Putting invoice_status=unpaid into object metadata looks convenient until the first admin filter arrives. Keep canonical metadata in the database.

Don't put an email address, school name, or other mutable personal data in the key. Use stable opaque IDs. Renaming a user then becomes a database update rather than a copy-and-delete migration of every object. It also keeps object names less revealing in logs and provider consoles.

One more boundary is easy to miss: a client-supplied filename is display data. Normalize it for the key, reject path separators and control characters, and keep the untouched display name in the database. The server, not the browser, assembles the tenant prefix.

Upload API threat model

The dangerous failure isn't a slow upload. It's a valid signed URL for the wrong tenant's object.

Model the upload as a small state machine: pending_upload, uploaded, ready, or rejected. Generate the object key once and persist it with the pending row. A retry reuses that document ID instead of allocating a second logical document. This idempotency reflex prevents a lost response from turning one classroom recording into two database records with unclear ownership.

Watch for three signals. First, any object key whose leading tenant segment disagrees with its database row is an isolation violation. Second, an uploaded callback for a document still marked ready is a duplicate delivery and should become a no-op. Third, pending_upload rows older than the signing window identify abandoned attempts; they are reconciliation work, not proof that bytes may be deleted blindly.

A 403 from the storage service can be an expired signature, a method mismatch, or a policy denial. Keep the request ID, document ID, tenant ID, HTTP method, and object key in structured logs, but never log the signed query string. I'm not sure a single expiry duration fits every campus network; measure completion time by file-size band, then choose the shortest window that doesn't turn ordinary uploads into retries.

No guesswork here.

Can private user documents use tenant prefixes and database metadata?

The application accepts metadata first, authenticates the user, verifies tenant membership, allocates the document ID, and writes the pending database row. It then asks storage for a presigned URL bound to the exact private object key and upload operation. The browser sends the bytes directly to that URL and must not attach the platform Authorization header to the returned URL. For large media, multipart upload keeps retries limited to failed parts; PDF, DOCX, and invoice files can use the same ownership model even when their transfer strategy is simpler.

The following complete Go program derives and validates a key before persisting the canonical record, then checks the configured private bucket through Infrai's verified bucket-get route. Set INFRAI_API_ORIGIN, INFRAI_API_KEY, and INFRAI_BUCKET in the process environment. Keeping the origin in configuration also keeps deployment policy out of source control. The request has an explicit method, bounded retries for 429, and useful error bodies; no upload payload or presign response field is guessed.

package main

import (
    "errors"
    "fmt"
    "io"
    "net/http"
    "net/url"
    "os"
    "path"
    "regexp"
    "strconv"
    "strings"
    "time"
)

var opaqueID = regexp.MustCompile(`^[A-Za-z0-9_-]{1,64}$`)

type Document struct {
    ID           string
    TenantID     string
    UserID       string
    ObjectKey    string
    OriginalName string
    MIMEType     string
    Status       string
}

func newPendingDocument(tenantID, userID, documentID, filename, mimeType string) (Document, error) {
    for label, value := range map[string]string{
        "tenant ID": tenantID, "user ID": userID, "document ID": documentID,
    } {
        if !opaqueID.MatchString(value) {
            return Document{}, fmt.Errorf("invalid %s", label)
        }
    }

    cleanName := path.Base(strings.ReplaceAll(filename, "\\", "/"))
    if cleanName == "." || cleanName == "/" || cleanName == "" || strings.ContainsAny(cleanName, "\x00\r\n") {
        return Document{}, errors.New("invalid filename")
    }

    key := strings.Join([]string{tenantID, userID, documentID, cleanName}, "/")
    return Document{
        ID: documentID, TenantID: tenantID, UserID: userID,
        ObjectKey: key, OriginalName: filename, MIMEType: mimeType,
        Status: "pending_upload",
    }, nil
}

func retryDelay(header string, attempt int) time.Duration {
    if seconds, err := strconv.Atoi(header); err == nil && seconds >= 0 {
        return time.Duration(seconds) * time.Second
    }
    if when, err := http.ParseTime(header); err == nil {
        if delay := time.Until(when); delay > 0 {
            return delay
        }
    }
    return time.Duration(1<<attempt) * time.Second
}

func checkBucket(client *http.Client, origin, apiKey, bucket string) error {
    route := strings.Replace(
        "/v1/storage/bucket/get/{bucket}", "{bucket}", url.PathEscape(bucket), 1,
    )
    endpoint := strings.TrimRight(origin, "/") + route

    for attempt := 0; attempt < 4; attempt++ {
        req, err := http.NewRequest(http.MethodGet, endpoint, nil)
        if err != nil {
            return err
        }
        req.Header.Set("Authorization", "Bearer "+apiKey)

        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 == http.StatusTooManyRequests {
            time.Sleep(retryDelay(resp.Header.Get("Retry-After"), attempt))
            continue
        }
        if resp.StatusCode < 200 || resp.StatusCode >= 300 {
            return fmt.Errorf("bucket check returned %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
        }
        return nil
    }
    return errors.New("bucket check remained rate limited after four attempts")
}

func main() {
    doc, err := newPendingDocument(
        "school_42", "teacher_17", "doc_8f31",
        "calculus-week-03.mp4", "video/mp4",
    )
    if err != nil {
        panic(err)
    }

    origin := os.Getenv("INFRAI_API_ORIGIN")
    apiKey := os.Getenv("INFRAI_API_KEY")
    bucket := os.Getenv("INFRAI_BUCKET")
    if origin == "" || apiKey == "" || bucket == "" {
        panic("INFRAI_API_ORIGIN, INFRAI_API_KEY, and INFRAI_BUCKET are required")
    }
    client := &http.Client{Timeout: 15 * time.Second}
    if err := checkBucket(client, origin, apiKey, bucket); err != nil {
        panic(err)
    }
    fmt.Printf("%s %s\n", doc.Status, doc.ObjectKey)
}
Enter fullscreen mode Exit fullscreen mode

Persist the record and key in one database transaction before signing. On completion, issue a HEAD-style verification through the provider adapter and compare the expected identity and content attributes your application records. MIME type supplied by the browser is a hint, not a security verdict. A scanning or media-processing worker can make the final state transition idempotently, keyed by document ID.

For Infrai, this workflow is available through plain HTTP under one account. Its relevant operational advantage is consolidation: the storage adapter can share one key and one bill with other backend capabilities instead of adding another credential and invoice to the runbook. It also exposes a public, self-describing discovery surface, so an integration can take its method, path, and request schema from discovery rather than relying on a guessed REST convention. The fit is strongest when that control-plane simplicity matters more than storage-specific advanced controls.

Provider comparison for tenant control

The data pattern survives a provider change, but the control-plane decision still matters. Evaluate direct offerings and aggregators against the controls the workload actually needs.

Option Good fit Reason to choose something else
AWS S3 Teams already operating AWS identity, bucket policy, and storage tooling A separate cloud account, key set, and bill may be unwanted for a small cross-provider platform
Cloudflare R2 Applications already using Cloudflare's developer platform and direct object-storage controls Teams standardized on another cloud may prefer its native identity and governance plane
Azure Blob Storage Organizations whose tenant governance already lives in Azure It adds a distinct platform boundary when the rest of the backend is managed elsewhere
Google Cloud Storage Workloads governed through Google Cloud projects and IAM It isn't among Infrai's covered storage vendors, so choose it directly when GCP-native governance is the requirement
Infrai Teams prioritizing one key, one bill, plain REST, and a consistent backend control plane Use a native provider when the advanced storage controls below are mandatory

The catch is real. Infrai is not suitable for static-site hosting, image hosting, or permanent public links because objects don't have public or public-read access and public_url remains null. It also lacks object versioning and object lock, so accidental overwrite recovery and financial-grade WORM retention call for an external system or a native provider that supplies those controls. Strict concurrent exclusion can't rely on conditional If-Match writes; serialize through a queue or coordinate ownership in the database.

Browser uploads need an early proof-of-concept because self-service bucket CORS configuration isn't available as an independent control. Lifecycle expiry has a one-day minimum, and abandoned multipart fragments don't have an automatic cleanup rule, so schedule reconciliation explicitly. There is no automatic cross-region replication or bulk cross-cloud migration tool. Vendor coverage includes R2, S3, OSS, and COS, but not GCS or B2. Trial credit can't pay for persistent writes. None of these boundaries invalidates the private-prefix pattern; they decide which control plane should implement it.

Stick with AWS S3, Cloudflare R2, Azure Blob Storage, or Google Cloud Storage directly when existing cloud IAM and governance are already the organizational source of truth. Pick an abstraction such as Infrai when credential and billing consolidation is the higher operational concern and its capability boundaries match the workload. Price isn't the deciding signal. Isolation controls and recovery requirements are.

Evaluation harness and rollback drill

Verification should exercise authorization, not just upload throughput. In a staging environment, create two tenants and two users per tenant. Have each user request an upload, then attempt to substitute the other tenant's document ID at metadata creation, signing, completion, download, and delete boundaries. Every denial belongs in an audit trail. Also verify that listing a tenant prefix never feeds an API response until the application has checked tenant membership.

Run the failure cases: expire a signed URL before use, repeat the completion event, interrupt a multipart transfer after several parts, and deliver the processing message twice. The expected outcome is boring. No duplicate document row appears, no state moves backward from ready, and no user gains a URL for an object outside the authorized database row.

Rollback is primarily a control-plane action. Disable new signing, leave existing private objects private, and keep serving already authorized reads while the application is rolled back. If a deployment wrote malformed keys, stop reconciliation deletion first; derive the affected set by joining database rows to exact keys, review tenant boundaries, and only then perform idempotent cleanup. For a provider migration, dual-write metadata before bytes, verify object identity, and switch reads by document state rather than by an all-at-once bucket flag.

The runbook should name an owner for stale pending rows and multipart fragments, define the signing freeze switch, and record how to revoke application credentials. It should also state the invariant in one line: a document is reachable only through an authorized database row whose tenant and object key agree.

That's the gate.

References

Top comments (0)