File Storage (S3)
How the platform stores and serves user-uploaded files and server-generated documents. Implements P27.
Model: single bucket, org-prefixed keys
Every file lives in one S3 bucket for the platform. The bucket is partitioned per tenant by org-prefixed object keys:
s3://{bucket}/
├── {org_id}/
│ ├── signatures/specialists/{specialist_id}/signature.png
│ ├── documents/{document_id}/{uuid}.pdf
│ ├── uploads/forms/{form_id}/field_{custom_field_id}/{uuid}.{ext}
│ ├── templates/logos/logo.png
│ ├── appointment-files/{appointment_id}/{uuid}.{ext}
│ └── exercise-assets/{exercise_id}/{uuid}.{ext}
└── audit-archive/{org_id}/{year}/{month}.jsonl.gzWhy single bucket. AWS soft-caps accounts at 100 buckets (hard ceiling 1,000 with limit increase). White-label-for-clinics targets thousands of tenants — a per-org bucket model puts a scaling cliff in the foundation. The single-bucket model also keeps the audit archive co-resident with tenant uploads so the warm-tier retention pipeline (P10) is one place.
Why this is still safe. Cross-tenant isolation is enforced at three layers:
- App-level org-scope guard. Every read, write, presign, and delete in
internal/integration/s3/runsValidateOrgScope(orgID, key)before the SDK call. A request authenticated as Org A cannot touch an Org B key — even if a caller hands in a fully-formed raw key string. The integration test exercises this directly against LocalStack. - Bucket-level Block Public Access. All four toggles ON. No object can be made world-readable.
- Bucket policy.
services/api/deploy/s3-bucket-policy.jsondenies unencrypted uploads, denies non-TLS access, and disables ACL changes.
BYO bucket (deferred). A clinic that contractually needs its own bucket (or its own AWS account) can plug one in via organization_integrations (P30). The package-level call surface does not change. v1 ships only the platform-wide model.
Surfaces
Each upload names a surface. The surface drives the MIME allow-list, the recommended size limit, and the path prefix. Adding a surface is a code change in internal/integration/s3/surfaces.go, not a runtime configuration.
| Surface | MIME allow-list | Default cap | Path prefix |
|---|---|---|---|
signatures | image/png, image/jpeg | 2 MB | signatures/ |
documents | application/pdf | 25 MB | documents/ |
forms-upload | application/pdf, image/png, image/jpeg, image/webp | 10 MB | uploads/forms/ |
logos | image/png, image/jpeg, image/webp | 1 MB | templates/logos/ |
appointment-files | application/pdf, image/png, image/jpeg, image/webp | 25 MB | appointment-files/ |
exercise-assets | video/mp4, video/webm, image/png, image/jpeg | 250 MB | exercise-assets/ |
Hard ceiling: 250 MB. No surface may exceed this regardless of caller-supplied limits. Surfaces that legitimately need larger uploads must register a new surface and document why.
SVG is intentionally excluded from logos. SVG is XML and can carry script payloads that browsers execute when the file is served back. Logos render as raster.
MIME validation
Every upload is validated three ways before bytes leave the process:
- The declared
Content-Typeheader must appear in the surface's allow-list. - The sniffed
Content-Type(fromhttp.DetectContentTypeover the leading 512 bytes of the body) must appear in the surface's allow-list. - The declared and sniffed types must agree.
This catches the "application/pdf" header on a PNG body, executable bodies declared as images, and any other content-type lie. The canonical (sniffed) MIME is what the platform stores; the declared header is never trusted.
Signed URLs
Files are accessed via short-lived presigned URLs.
| Operation | TTL | Constant |
|---|---|---|
| Read | 15 minutes | s3.ReadURLTTL |
| Write | 5 minutes | s3.WriteURLTTL |
Why short. A leaked write URL is strictly worse than a leaked read URL (it lets an attacker overwrite content), so writes have the tighter window. Both are short enough that screenshot or referer-header leakage is bounded; long enough that legitimate browser flows (download → save) succeed.
Signed URLs are still org-scoped. PresignRead(orgID, key) and PresignWrite(orgID, key) re-run the org-scope guard before issuing the URL. A signed URL leaving the package always belongs to the same org as the authenticated request.
Server-side encryption
Every PutObject sets ServerSideEncryption: AES256 (SSE-S3). The bucket policy denies any PutObject without that header, so both layers stay in sync.
KMS-based encryption (SSE-KMS) lands alongside the rest of the KMS wiring in 1A.3 + 1E.3. When that flips, the bucket policy will gain a corresponding condition on s3:x-amz-server-side-encryption-aws-kms-key-id.
Path traversal protection
OrgScopedKey(orgID, segments...) is the single way callers build keys. It rejects:
- Empty segments.
.and..segments.- Segments containing
/or NUL bytes. - Segments longer than 256 bytes.
ValidateOrgScope(orgID, key) re-runs the same checks on the tail of any externally-supplied key. Defense in depth: even if an attacker controls a segment, they cannot escape the org prefix.
Configuration
| Env var | Required | Notes |
|---|---|---|
AWS_BUCKET_NAME | prod yes | Single platform bucket |
AWS_REGION | prod yes | The bucket's region |
AWS_ACCESS_KEY_ID | dev/test only | Production uses instance role / IRSA |
AWS_SECRET_ACCESS_KEY | dev/test only | Production uses instance role / IRSA |
AWS_S3_ENDPOINT_URL | dev/test only | Point at MinIO or LocalStack; empty in production |
AWS_S3_USE_PATH_STYLE | dev/test only | LocalStack and MinIO require path-style URLs (endpoint/bucket/key) |
In dev/test, leaving AWS_BUCKET_NAME empty skips object storage initialization and logs a warning. Production startup refuses to run without it.
Local development
A LocalStack container exposes an S3-compatible endpoint at http://localhost:4566. Set:
AWS_BUCKET_NAME=restartix-local
AWS_REGION=us-east-1
AWS_ACCESS_KEY_ID=test
AWS_SECRET_ACCESS_KEY=test
AWS_S3_ENDPOINT_URL=http://localhost:4566
AWS_S3_USE_PATH_STYLE=trueThe bucket itself must be pre-created against LocalStack — the platform does not auto-create buckets. The integration test (internal/integration/s3/integration_test.go, build tag integration) creates a bucket per run inside its own LocalStack container.
Tests
- Unit (
make test).internal/integration/s3/*_test.gocovers key construction, MIME validation, presign TTLs, surface registry shape, and the upload size-cap path against a fake S3 client. - Integration (
make test-integration).internal/integration/s3/integration_test.gospins up LocalStack via testcontainers-go and exercises the full SDK path: upload → SSE applied → presigned read URL round-trips the original bytes → cross-org isolation across upload, presign, and delete → audit-archive prefix not accepted by tenant-facing operations.
Deployment status
The bucket itself, the bucket policy application, the CORS rules, lifecycle rules, and the staging KMS key are all owned by 1E.3. The application code, the policy template, and the surface registry land at 1A.8 so they can be reviewed and tested independently of the AWS provisioning step.