Skip to content

Storage

kneshi edited this page May 10, 2026 · 1 revision

Storage

Article30 uses RustFS as a private S3-compatible object store for uploaded documents and follow-up attachments. RustFS is not exposed to the public internet: it runs only on the internal Docker network, and the backend is the only component that talks to it.

Why a backend proxy?

Every document or attachment fetch goes:

browser  --(session cookie + per-entity ACL)-->  backend  --(internal S3)-->  RustFS

Earlier iterations issued AWS-style presigned URLs and let the browser fetch directly. That model required RustFS to be reachable from the public network, which conflicted with hardened deployments and made per-entity authorization difficult to enforce after the URL was minted. The current model:

  • Keeps RustFS's S3 port (:9000) private. No CDN, no TLS termination, no CORS rules to maintain.
  • Re-validates authorization on every fetch (the URL is not credentialed; the session cookie is).
  • Works transparently with <a href>, <img src>, and the browser's native PDF viewer (Range requests are supported).

Endpoints

Method Path Purpose
GET /api/documents/:id/download Stream a Document linked to a Treatment / Violation / Checklist Item.
GET /api/follow-up/attachments/:id/download Stream a Violation- or DSR-linked attachment.

Both endpoints return:

  • 200 OK + the file bytes for a normal request.
  • 206 Partial Content + Content-Range for Range requests (PDFs in the browser viewer issue these).
  • 416 Range Not Satisfiable for an out-of-bounds Range.
  • 403 Forbidden for an unauthorised role.
  • 404 Not Found for a missing object or an ownership failure (we deliberately do not distinguish, to avoid leaking object existence via UUID enumeration).

Responses set:

  • Content-Type from the persisted mimeType (validated at upload), not from S3.
  • Content-Disposition: inline; filename*=UTF-8''<encoded> (RFC 8187) so PNGs and PDFs preview natively while DOCX/XLSX trigger the download dialog.
  • Cache-Control: private, no-store and X-Content-Type-Options: nosniff.

Authorization model

Source Linked entity PROCESS_OWNER allowed when...
Document TREATMENT Owns the treatment (createdBy or assignedTo).
Document VIOLATION Created/assigned the violation, OR owns a treatment linked via ViolationTreatment.
Document CHECKLIST_ITEM Always (org-wide artefact).
FollowUpAttachment VIOLATION Same as Document/VIOLATION.
FollowUpAttachment DSR Owns a treatment linked via DsrTreatmentProcessingLog.

ADMIN, DPO, EDITOR, AUDITOR are not scoped beyond the role gate. Soft-deleted attachments (storageKey: null or deletedAt != null) return 404 for everyone.

The role gate constants live in shared/src/constants/roles.ts:

  • DOCUMENT_READ_ROLES for /api/documents/:id/download
  • FOLLOW_UP_READ_ROLES for /api/follow-up/attachments/:id/download

Operational notes

  • S3_ENDPOINT should always be the internal hostname (http://rustfs:9000 in compose). It is the only S3 address the backend uses.
  • The dev environment runs the backend on the host, so .env.dev.example keeps S3_ENDPOINT=http://localhost:9000. The build/dev.compose.yml overlay publishes port 9000 for that reason.
  • The RustFS console (port 9001) is independent of object access; expose or hide it per ops policy.
  • Backups: the rustfsdata Docker volume holds all uploaded objects and should be snapshotted alongside pgdata.

Limits

  • Max object size: 10 MB (enforced at upload by FileInterceptor and the service-level check).
  • Allowed MIME types: PDF, JPEG, PNG, DOCX, XLSX. Anything else is rejected at upload time.
  • No CDN; throughput is bounded by the backend's process count and Node's stream throughput. Adequate for the current Article 30 register workload (small attachments, low concurrency); revisit if the use case grows.

Clone this wiki locally