Skip to content

AWS S3 Integration

AWS S3 provides file storage for uploads, documents, and signatures.

Features

  • Private ACL by default
  • Signed URLs for access (15-minute expiry for reads, 5-minute expiry for writes)
  • 250MB max file size
  • Supports images, files, videos, audios

Go Client

go
// internal/integration/s3/client.go

type Client struct {
    s3Client       *s3.Client
    presignClient  *s3.PresignClient
    bucket         string
    signedURLExpiry time.Duration
}

func NewClient(cfg Config) (*Client, error) {
    awsCfg, err := config.LoadDefaultConfig(context.Background(),
        config.WithRegion(cfg.Region),
        config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(
            cfg.AccessKeyID, cfg.SecretAccessKey, "",
        )),
    )
    if err != nil {
        return nil, err
    }

    s3c := s3.NewFromConfig(awsCfg)
    return &Client{
        s3Client:       s3c,
        presignClient:  s3.NewPresignClient(s3c),
        bucket:         cfg.BucketName,
        signedURLExpiry: 15 * time.Minute,
    }, nil
}

// Upload stores a file under the org-scoped key prefix.
// See "Cross-Tenant Isolation" section for orgScopedKey() implementation.
func (c *Client) Upload(ctx context.Context, orgID int64, path string, body io.Reader, contentType string) (string, error) {
    key, err := orgScopedKey(orgID, path)
    if err != nil {
        return "", err
    }
    _, err = c.s3Client.PutObject(ctx, &s3.PutObjectInput{
        Bucket:      &c.bucket,
        Key:         &key,
        Body:        body,
        ContentType: &contentType,
        ACL:         types.ObjectCannedACLPrivate,
    })
    if err != nil {
        return "", fmt.Errorf("upload to S3: %w", err)
    }
    return key, nil
}

func (c *Client) GetSignedURL(ctx context.Context, orgID int64, path string) (string, error) {
    key, err := orgScopedKey(orgID, path)
    if err != nil {
        return "", err
    }
    presigned, err := c.presignClient.PresignGetObject(ctx, &s3.GetObjectInput{
        Bucket: &c.bucket,
        Key:    &key,
    }, func(opts *s3.PresignOptions) {
        opts.Expires = c.signedURLExpiry
    })
    if err != nil {
        return "", err
    }
    return presigned.URL, nil
}

func (c *Client) Delete(ctx context.Context, orgID int64, path string) error {
    key, err := orgScopedKey(orgID, path)
    if err != nil {
        return err
    }
    _, err = c.s3Client.DeleteObject(ctx, &s3.DeleteObjectInput{
        Bucket: &c.bucket,
        Key:    &key,
    })
    return err
}

Bucket Structure

S3 Key Structure:
s3://{bucket}/
├── {org_id}/
│   ├── uploads/
│   │   └── forms/
│   │       └── {form_id}/
│   │           └── field_{custom_field_id}/{filename}
│   ├── signatures/
│   │   └── specialists/
│   │       └── {specialist_id}/signature.png
│   ├── documents/
│   │   └── {document_id}/
│   │       ├── patient.pdf
│   │       ├── specialist.pdf
│   │       └── admin.pdf
│   └── templates/
│       └── logos/logo.png
└── audit-archive/
    └── {org_id}/
        └── {year}/{month}.jsonl.gz

Cross-Tenant Isolation

Problem

Single bucket for all orgs. If a signed URL leaks or S3 key is guessable, cross-org file access is possible.

Mitigation: Org-Scoped Key Prefixes

Every S3 operation validates the org prefix:

go
// internal/integration/s3/client.go

// orgScopedKey ensures the S3 key is under the org's prefix.
// Prevents path traversal (e.g., "../../other-org/file.pdf").
func orgScopedKey(orgID int64, path string) (string, error) {
    // Clean the path to prevent traversal
    cleaned := filepath.Clean(path)
    if strings.Contains(cleaned, "..") {
        return "", fmt.Errorf("invalid path: contains traversal")
    }

    key := fmt.Sprintf("%d/%s", orgID, cleaned)
    return key, nil
}

func (c *Client) Upload(ctx context.Context, orgID int64, path string, body io.Reader, contentType string) (string, error) {
    key, err := orgScopedKey(orgID, path)
    if err != nil {
        return "", err
    }

    _, err = c.s3Client.PutObject(ctx, &s3.PutObjectInput{
        Bucket:      &c.bucket,
        Key:         &key,
        Body:        body,
        ContentType: &contentType,
        ACL:         types.ObjectCannedACLPrivate,
    })
    if err != nil {
        return "", fmt.Errorf("upload to S3: %w", err)
    }
    return key, nil
}

func (c *Client) GetSignedURL(ctx context.Context, orgID int64, path string) (string, error) {
    key, err := orgScopedKey(orgID, path)
    if err != nil {
        return "", err
    }

    // Verify the key starts with the org prefix (defense in depth)
    expectedPrefix := fmt.Sprintf("%d/", orgID)
    if !strings.HasPrefix(key, expectedPrefix) {
        return "", fmt.Errorf("access denied: key does not belong to org %d", orgID)
    }

    presigned, err := c.presignClient.PresignGetObject(ctx, &s3.GetObjectInput{
        Bucket: &c.bucket,
        Key:    &key,
    }, func(opts *s3.PresignOptions) {
        opts.Expires = c.signedURLExpiry
    })
    if err != nil {
        return "", err
    }
    return presigned.URL, nil
}

Signed URL Security

  • URLs expire after 15 minutes (read) or 5 minutes (write)
  • URLs are key-specific — a signed URL for 1/uploads/forms/201/file.pdf cannot access 2/uploads/forms/201/file.pdf
  • Path traversal is blocked (.. in paths rejected)
  • Org ID comes from the authenticated user context (RLS session), not from the request

S3 Bucket Policy

Additional defense layer:

json
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "DenyUnencryptedUploads",
            "Effect": "Deny",
            "Principal": "*",
            "Action": "s3:PutObject",
            "Resource": "arn:aws:s3:::restartix-uploads/*",
            "Condition": {
                "StringNotEquals": {
                    "s3:x-amz-server-side-encryption": "AES256"
                }
            }
        },
        {
            "Sid": "DenyPublicAccess",
            "Effect": "Deny",
            "Principal": "*",
            "Action": "s3:GetObject",
            "Resource": "arn:aws:s3:::restartix-uploads/*",
            "Condition": {
                "StringNotLike": {
                    "aws:Referer": "https://*.restartix.com/*"
                }
            }
        }
    ]
}

Additionally: S3 Block Public Access is enabled at the bucket level. No object can ever be public.

Testing

go
func TestS3OrgIsolation(t *testing.T) {
    // Upload file as org 1
    key1, _ := s3Client.Upload(ctx, 1, "uploads/test.pdf", body, "application/pdf")
    assert.True(t, strings.HasPrefix(key1, "1/"))

    // Attempt to access org 1's file as org 2 — should fail
    _, err := s3Client.GetSignedURL(ctx, 2, "uploads/test.pdf")
    // Returns signed URL for "2/uploads/test.pdf" which doesn't exist — no cross-org access

    // Attempt path traversal — should fail
    _, err = s3Client.Upload(ctx, 2, "../1/uploads/test.pdf", body, "application/pdf")
    assert.Error(t, err) // Rejected by orgScopedKey()
}