Get a quote

Document Management in SaaS: File Uploads, Versioning, and Tenant-Scoped Access in Go

Document management is a foundational requirement for most business SaaS products. Contracts, invoices, compliance documents, staff files, and client records all need secure upload, versioned storage, and tenant-scoped access control. This is the architecture we use for document management in Go SaaS backends serving business clients across Lebanon and the MENA region.

Document management is a foundational requirement for most business SaaS products. Contracts, invoices, compliance documents, staff files, and client records all need secure upload, versioned storage, and tenant-scoped access control. This is the architecture we use for document management in Go SaaS backends serving business clients across Lebanon and the MENA region.

The core architecture: presigned uploads with S3

The most efficient architecture for document upload in a Go SaaS backend uses client-side direct upload to S3 via presigned URLs, rather than routing the file through the application server.

The flow:

  1. The client requests an upload URL from the API
  2. The Go backend generates a presigned S3 PUT URL (valid for 15 minutes) and returns it to the client
  3. The client uploads the file directly to S3 using the presigned URL
  4. After the upload completes, the client calls the API to confirm the upload
  5. The Go backend verifies the file exists in S3 and creates the document record in PostgreSQL

This approach keeps the application server out of the file transfer entirely. A 50MB PDF uploads from a client in Beirut directly to an S3 bucket in the eu-west-1 or me-south-1 region, bypassing the application server. The application server handles only the metadata operations (creating the presigned URL, confirming the upload, querying document lists), which are lightweight.

S3 key structure for multi-tenant document storage

The S3 key structure must enforce tenant isolation at the storage layer. The convention that works:

tenant-documents/{tenant_id}/{document_id}/{version_id}/{filename}

This structure means:

  • All documents for a tenant are under the same S3 prefix, making lifecycle policies and cross-tenant audits straightforward
  • Each document has a UUID document ID that is stable across versions
  • Each version has a UUID version ID, allowing multiple versions to coexist in storage
  • The original filename is preserved at the end of the key for human readability when browsing the bucket

S3 bucket policies should restrict access to the prefix for each tenant. With IAM, a role scoped to a specific tenant prefix can be used to grant that tenant's processes access to their documents without being able to read other tenants' prefixes.

The document metadata model in PostgreSQL

S3 holds the bytes. PostgreSQL holds the metadata and the access control records.

CREATE TABLE documents (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id UUID NOT NULL,
    name TEXT NOT NULL,
    description TEXT,
    category TEXT NOT NULL,
    created_by UUID REFERENCES users(id),
    created_at TIMESTAMPTZ DEFAULT NOW(),
    current_version_id UUID
);

CREATE TABLE document_versions (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    document_id UUID REFERENCES documents(id),
    tenant_id UUID NOT NULL,
    version_number INT NOT NULL,
    s3_key TEXT NOT NULL,
    file_size_bytes BIGINT NOT NULL,
    content_type TEXT NOT NULL,
    original_filename TEXT NOT NULL,
    upload_status TEXT NOT NULL DEFAULT 'pending',
    uploaded_by UUID REFERENCES users(id),
    uploaded_at TIMESTAMPTZ DEFAULT NOW(),
    checksum_sha256 TEXT,
    UNIQUE (document_id, version_number)
);

The upload_status field in document_versions is key to the presigned upload flow. A new version starts in pending status when the presigned URL is generated. It transitions to uploaded after the client confirms and the backend verifies the file exists in S3. Pending versions that are never confirmed (failed uploads, abandoned uploads) can be cleaned up by a background job that runs hourly.

Generating presigned download URLs

Documents should never be served directly from public S3 URLs. Access is controlled at the application layer: the API verifies that the requesting user belongs to the correct tenant and has permission to access the specific document, then generates a short-lived presigned GET URL for the S3 object.

func (s *DocumentService) GetDownloadURL(
    ctx context.Context,
    documentID, versionID uuid.UUID,
) (string, error) {
    tenant, ok := TenantFromContext(ctx)
    if !ok {
        return "", errors.New("no tenant context")
    }
    version, err := s.repo.GetVersion(ctx, documentID, versionID)
    if err != nil {
        return "", err
    }
    // Verify tenant ownership
    if version.TenantID != tenant.ID {
        return "", errors.New("access denied")
    }
    presignClient := s3.NewPresignClient(s.s3Client)
    req, err := presignClient.PresignGetObject(ctx,
        &s3.GetObjectInput{
            Bucket: aws.String(s.bucket),
            Key:    aws.String(version.S3Key),
        },
        s3.WithPresignExpires(15*time.Minute),
    )
    if err != nil {
        return "", err
    }
    return req.URL, nil
}

The presigned URL expires in 15 minutes. This is appropriate for browser-based downloads where the user will click within seconds. For PDF viewers that embed the URL (common in Lebanese enterprise software where documents are previewed in-browser), use a longer expiry or implement a token-based proxy that refreshes the presigned URL before it expires.

File type validation and virus scanning

Accepting arbitrary file uploads from users introduces two risks: malicious file types disguised with innocent extensions, and malware uploaded to your storage.

For file type validation in Go, read the first 512 bytes of the uploaded file from S3 after the upload completes and use the http.DetectContentType function or a MIME library to verify the actual content type matches the declared content type. A file with a .pdf extension that has the magic bytes of an executable should be rejected.

For virus scanning in production MENA SaaS deployments, the most practical approach is to use ClamAV running as a sidecar service, or AWS GuardDuty Malware Protection for S3 (available in me-south-1 for Middle East deployments). Documents that fail virus scanning are marked as quarantined and are not accessible for download until reviewed.

Document access logging for compliance

Business clients in Lebanon and the Gulf frequently operate under regulatory frameworks that require audit logs of document access. A compliance audit trail records every download, every upload, every permission change, and every deletion:

CREATE TABLE document_access_log (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id UUID NOT NULL,
    document_id UUID NOT NULL,
    version_id UUID,
    user_id UUID NOT NULL,
    action TEXT NOT NULL,   -- upload, download, delete, share
    ip_address INET,
    user_agent TEXT,
    occurred_at TIMESTAMPTZ DEFAULT NOW()
);

This table is append-only. No row in the access log is ever updated or deleted. For compliance purposes, the log must be immutable. In PostgreSQL, you can enforce this by granting the application role only INSERT permission on this table, with no UPDATE or DELETE.

Key lessons from production

Never route file bytes through the application server. Presigned S3 URLs keep large files out of the application tier, which cannot scale horizontally as easily as S3.

Use a consistent S3 key structure that includes the tenant ID at the first level. Cross-tenant isolation at the storage layer is a defense-in-depth against application bugs.

Always verify file existence in S3 after a presigned upload before marking the version as uploaded. The client can report a successful upload that never actually occurred.

Implement file type validation after upload, not before. Content-type headers from the browser are not trustworthy. Read the file magic bytes from S3.

Make the document access log append-only. Compliance audit trails that can be modified after the fact are not audit trails.

Free PDF Download

Enjoying this article?

Enter your email and get a clean, formatted PDF of this article - free, no spam.

Free. No spam. Unsubscribe any time.

Not sure where to start?

Voxire builds SaaS products with production document management for business clients across Lebanon and the MENA region. If you are designing a document storage architecture or adding document management to an existing SaaS product, we can help.

https://voxire.com/get-a-quote/

Back to blog
Chat on WhatsApp