Skip to main content

Documents & Attachments — How it works

Overview

The document engine (M13-006) stores files that belong to a patient, an encounter or a staff member — scanned referral letters, consent forms, identity documents, provider signatures — without putting bytes in the database. The row holds metadata and a storage key; the object lives in S3 (or LocalStack locally). Downloads are gated on a virus-scan verdict.

Data model & ownership

TablePurpose
documentThe metadata row: tenant_id, facility_id, one of patient_id/encounter_id/staff_profile_id, document_type_concept_id, title, content_type, size_bytes, checksum_sha256, storage_key, scan_status, scan_detail.

Migration: document/001. It carries three constraints worth knowing:

  • ck_document_has_subject — a document must belong to something: a patient, an encounter or a staff profile.
  • uq_document_storage_key — partial unique on live rows, so two documents can never point at one object.
  • ix_document_patient / ix_document_staff — partial indexes for the two list paths.

The document type is a concept, not an enum and not a string: a facility that files a new kind of paperwork adds a concept, not a release.

form_submission_id (migration document/003, M34-001 part) — the form submission a FILE form field attached this document to; null for every document nothing has attached. Stamped by the form engine in the accepting submission's transaction, so the link and the submission commit or roll back together. Logical reference, no foreign key, like every other subject column. The form engine's side of the contract — who may reference a document, and when the stamp happens — is in the form engine doc under Reference fields.

Who may read a document

Every read is decided in DocumentServiceImpl, not in the controller. Engines call each other in-process, so a controller guard would only move the gap to the next caller.

The document's subject decides, and there are three:

SubjectRule
patient_idPatientAccessGuard.authorizeOrNotFound(patientId, READ, "Document not found") — the same object-level decision the clinical engine makes.
encounter_id onlyThe encounter is resolved through the clinical engine's EncounterService, which authorizes the patient behind it. Its "Encounter not found" is translated to this path's own message.
staff_profile_id onlyNot patient-scoped. The session's tenant and the document.document.read permission are the whole control — see the gap below.
form_submission_id set (M34-001)Uploader, a routing participant of the owning submission (initiator or task assignee/actor on its workflow instance), or holder of document.document.read. List: GET /documents?formSubmissionId= — newest first.

The tenant term comes first and applies to all three. It is read from the signed session (RequestContextHolder.requireCurrentTenantId()), never from the request, and a document belonging to another tenant answers exactly as a document that does not exist. The two list paths carry the tenant in the query rather than filtering afterwards.

A denial is indistinguishable from a miss. Every refusal is ResourceNotFoundException carrying the identical "Document not found" message an unknown id produces (SEC-011). A 403 where a miss gives 404 would confirm that the id names a real document somebody else holds — and document ids travel in referral letters, URLs and support tickets.

Key rules & invariants

  • Bytes are never in the row. storage_key names an S3 object; S3DocumentStorage is the only thing that reads or writes it. Configured by uhp.documents (bucket, max-bytes, default 25 MiB = DOCUMENTS_MAX_BYTES=26214400). ⚠ max-bytes is not only an upload cap: the object is read into memory to checksum it, so it bounds per-request heap too. Raise it for larger scans knowing both halves.
  • Unknown is not the same as safe. Download is refused unless scan_status = CLEAN; both PENDING and a failed scan are refused, so wiring no scanner cannot become a way to serve unscanned files.
  • Downloads are served as attachments with Content-Disposition: attachment and X-Content-Type-Options: nosniff, so a stored HTML or SVG file cannot execute in the viewer's origin.
  • Nothing is logged about a document's content, name or subject. A filename is frequently a diagnosis.
  • Provider signatures live here (M13-006B). A provider's signature is a person_attribute of the seeded Signature type whose value is a document id, resolved to /api/v1/document/documents/{id}/content. A legacy off-platform URL resolves to nothing rather than being rendered — an <img src> a clinician's browser fetches from a third party is an exfiltration channel, and the whole point of owning the storage was to close it.

Virus scanning

A scheduled worker picks up documents that have not been scanned, streams them to a shared clamd, and records the verdict (DOC-SCAN-001). Until that landed, recordScanResult had no caller at all and no document was downloadable in any deployment.

Scan statusAnswer
CLEANthe bytes
INFECTED400 — "found to be infected and will not be served" (a verdict)
PENDING / FAILED400 — naming that the scan has not produced a verdict, rather than blaming the caller

Turning it on

document:
scan:
provider: clamav # `none` (default) scans nothing
host: clamd.uhp.svc
port: 3310

The worker also needs the scheduler profile, the same deployment that runs notification dispatch — the API pods deliberately run no timers.

provider: none means documents are never scanned and therefore never downloadable. It does not mean documents are fine. That asymmetry is deliberate: a deployment nobody has given a scanner serves nothing, rather than serving everything on the strength of nobody having looked. The safe state is the one you get by doing nothing.

Rules that are not negotiable

  • No endpoint records a scan result, and none may be added. A caller who can upload a document and then declare it clean has not been scanned; that removes the control rather than wiring it. NoScanResultEndpointTest scans every request mapping in core and fails the build if one ever calls recordScanResult or recordScanFailure — because the symptom of a missing scanner (documents "stuck" at PENDING) looks exactly like a bug, and the tempting fix is the wrong one.
  • A failed scan is not a clean one. An unreachable clamd, an unreadable object, an oversize refusal and an unrecognised reply are all FAILED. Unknown is not safe.
  • An infected document is kept, never deleted. It is the evidence of what was uploaded, and it is already unreachable because only CLEAN is served.
  • Retries are bounded (max-attempts, retry-after). A transient clamd restart recovers on its own; a file the scanner genuinely cannot read stops consuming a slot in every pass instead of being rescanned forever.
  • One resident signature set. ClamAV's database is 100+ MB and needs freshclam updates, so clamd is shared infrastructure the platform talks to — not a library copied into each module, which would mean N copies to keep current and N ways to be stale.
  • Nothing patient-derived is logged. The scan log carries the document id, the outcome and the signature name — never the title, the patient, or any bytes.

This compounds with CONF-002 (!348). uhp.documents.* — bucket, endpoint and keys — is not overridable in the shipped jar, so the engine runs with cloudProvider=local, bucket=uhp-documents, endpointUrl=null whatever an operator configures. Taken together: an upload may be landing somewhere nobody intended, and it can never be read back. Neither half is visible from the API, which returns a cheerful 201. Fixing the scanner without fixing CONF-002 would make those misplaced objects downloadable, so the storage configuration is the one to land first.

An infected document is kept, never deleted: it is the evidence of what was uploaded, and it is already unreachable because only CLEAN is served.

API

Endpoints under /api/v1/document:

VerbPathNotes
POST/documentsmultipart upload; lands PENDING
GET/documents/{id}metadata; 404 when unknown or not readable by this caller
GET/documents/{id}/contentbytes; CLEAN only
GET/documents?patientId=&staffProfileId=list, confined to the session's tenant

Permissions document.document.read and document.document.write are declared by DocumentModuleDescriptor, which the catalog seeder reads to create the permission rows. A code no descriptor declares can never be granted to any role, super_admin included (SEC-016).

Known gaps

These are recorded rather than hidden.

  • The scanner must be configured to exist. DOC-SCAN-001 wired the worker, but a deployment that leaves document.scan.provider at none, or runs no scheduler pod, still scans nothing — so uploads succeed and downloads do not. That is the safe default, not a defect, but it is worth checking before concluding the engine is broken.
  • A staff document has no owner-level rule. A signature, a certificate and an identity document are the same thing to the access check: readable by anyone in the tenant holding document.document.read. That is deliberate — a provider signature is served to every clinician who opens an encounter that provider signed, so restricting staff documents to their owner would stop signatures rendering. Splitting them needs the administrative document-type lookup that M13-006C describes and that does not exist yet.
  • No void/delete endpoint, and no document-version history.
  • Demographic — provider signatures resolve through person_attribute.
  • Clinical — encounter-scoped attachments, and the access guard this engine reuses.
  • Concept dictionary — document types are concepts.

Why it is this way

Nothing is downloadable until a scanner says it is clean. The scan verdict gates retrieval, so an infected upload cannot be handed back out — and the platform refuses to guess in the absence of a scanner rather than defaulting to permissive.

Content and metadata are separated. Metadata is freely readable by anyone entitled to the record; the bytes are served only through the guarded endpoint. That is why the FHIR DocumentReference facade publishes description, size and hash but never a retrieval URL.

Traps

provider defaults to none, and none means documents are never marked cleanDocumentScanProperties. A deployment that never configures a scanner will accept uploads happily and refuse every download, which reads as a storage fault rather than a configuration one. It is on the go-live checklist for exactly that reason.

There is deliberately no "assume clean" implementationDocumentScanner. The obvious convenience — treat unscanned as clean when no provider is configured — is the one thing that would make the whole gate decorative.

Scanner implementations must stream. A document is arbitrarily large, and buffering it whole is how one upload takes the process down.

The scheduler never lets an exception escapeDocumentScanScheduler. An uncaught error from a scheduled method stops the schedule silently, so scanning would stop for everyone because of one bad document.

There is a retry ceiling. Without one, a permanently unscannable document is rescanned on every pass forever, and the queue never drains.