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.gzCross-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.pdfcannot access2/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()
}