Document ACL — Access Control for Clinical Documents
Audiences: developer, internal
Document access in Dudoxx HMS uses a two-layer model: role-based gates (4 constant arrays in
rbac.ts) for template management and document generation actions, and storage-layer access control (StorageService.streamFile+AccessContext) for file retrieval, which bypasses the RBAC interceptor and enforces ownership viaorganizationIdscoping before streaming bytes from MinIO via the S3 SDK (no presigned URL).
Business Purpose
Medical documents (lab reports, prescriptions, consent forms, intake summaries, diagnostic reports) must be accessible only to authorized staff within the patient's clinic. Over-restricting access blocks workflows; under-restricting it violates HIPAA/GDPR. A single role-check model cannot serve both download endpoints (which need fast passthrough for previews) and administrative endpoints (which need fine-grained role gates).
Dudoxx HMS solves this with a deliberate split:
- Role gates for document management actions —
DOCUMENT_MANAGE_ROLES,DOCUMENT_VIEW_ROLES,DOCUMENT_GENERATE_ROLES,DOCUMENT_REVIEW_ROLESarrays applied via@RequireRole()on template and generated-document endpoints. These gates control who can create/publish templates, generate new documents, and approve/review content. - Storage-layer access control for file retrieval —
DocumentsControlleris@RbacExempt(). The globalGatewayAuthGuardstill validates the JWT, but the RBAC enforcement interceptor is bypassed. Instead,StorageService.streamFile(id, context)enforces access by verifying the attachment belongs to the caller'sorganizationId, streams the bytes directly via the S3 SDK (s3Client.send(GetObjectCommand)), andsanitizeFilenameForHeader()protects response headers from injection. ddx-api is the ONLY MinIO client — no presigned URL is ever produced or fetched by the retrieval path (a prior presign+fetch()path 500'd under Node's undici withbad port; replaced 2026-06-30).
This pattern gives clinicians fast, low-overhead file streaming while ensuring they can only ever download attachments owned by their own organization.
Audiences
- Investor: HIPAA-compliant document access control is table-stakes for enterprise clinical buyers. The two-layer approach demonstrates production-grade security engineering without adding latency to file downloads.
- Clinical buyer (doctor/nurse/receptionist): Invisible at the UX level — staff access files they are authorized for; unauthorized access returns 403. Template management and document review actions require progressively elevated roles (DOCTOR/CLINIC_ADMIN/SUPER_ADMIN).
- Developer/partner: File retrieval:
GET /api/v1/documents/:id(bypasses RBAC interceptor;StorageServiceenforces org-scoped access). Template management:@RequireRole(...DOCUMENT_MANAGE_ROLES). Document generation:@RequireRole(...DOCUMENT_GENERATE_ROLES). Review/approval:@RequireRole(...DOCUMENT_REVIEW_ROLES). - Internal (ops/support): All document retrieval is org-scoped at the
Attachment.organizationIdfield (Prismaddx_api_main). Files stored in MinIO buckets per clinic slug (e.g.ddx-hamburg-clinic-documents).StorageService.AccessContextcarriesuserId,userRole,organizationId,ipAddress,userAgentfor audit logging.
Architecture
Document access has two layers.
Layer A: Role Gates (RBAC Interceptor — applies to management endpoints):
| Endpoint / controller | Required roles |
|---|---|
DocumentTemplatesController | DOCUMENT_MANAGE_ROLES / DOCUMENT_VIEW_ROLES |
GeneratedDocumentsController | DOCUMENT_GENERATE_ROLES / DOCUMENT_REVIEW_ROLES |
DocumentsController.getStats() | DOCUMENT_VIEW_ROLES |
DocumentsController.getRecent() | DOCUMENT_VIEW_ROLES |
DocumentsController.getMetadata() | DOCUMENT_VIEW_ROLES |
DocumentsController.updateMetadata() | DOCUMENT_MANAGE_ROLES |
DocumentsController.translateDocument() | DOCUMENT_MANAGE_ROLES |
Layer B: Storage-Layer Access (bypasses RBAC interceptor):
| Route | Mechanism | Notes |
|---|---|---|
DocumentsController | @RbacExempt() (class-level) | — |
GET :id | @RbacExempt inherited | — |
GET :id/preview, GET :id/download | StorageService.streamFile(id, AccessContext) | org-scoped validation |
| ” | ↳ s3Client.send(GetObjectCommand) | bytes fetched server-side via S3 SDK (NO presigned URL — ddx-api is the only MinIO client) |
| ” | sanitizeFilenameForHeader(filename) | HTTP header injection guard |
RBAC Arrays (rbac.ts):
| Array | Value |
|---|---|
DOCUMENT_MANAGE_ROLES | [DOCTOR, CLINIC_ADMIN, SUPER_ADMIN] |
DOCUMENT_VIEW_ROLES | [DOCTOR, STAFF, RECEPTIONIST, CLINIC_ADMIN, SUPER_ADMIN] |
DOCUMENT_GENERATE_ROLES | DOCUMENT_VIEW_ROLES (alias — same set) |
DOCUMENT_REVIEW_ROLES | [DOCTOR, CLINIC_ADMIN, SUPER_ADMIN] |
Key asymmetry: DOCUMENT_GENERATE_ROLES is intentionally aliased to DOCUMENT_VIEW_ROLES — any staff member who can view documents can trigger generation. Review/approval requires the narrower DOCUMENT_REVIEW_ROLES (no STAFF/RECEPTIONIST/NURSE).
Tech Stack & Choices
| Layer | Technology | Notes |
|---|---|---|
| Role arrays | UserRole[] constants in rbac.ts | Imported by both documents.controller.ts and document-templates.controller.ts — single source of truth |
| RBAC bypass | @RbacExempt() at controller class level | documents.controller.ts:104 — applied once; all methods inherit |
| Auth enforcement | GatewayAuthGuard (APP_GUARD) | Still fires on every request; @RbacExempt() only bypasses RbacEnforcementInterceptor |
| Storage access check | StorageService.streamFile(id, AccessContext) | Validates organizationId match; returns { buffer, metadata } in one call |
| MinIO file access | S3 SDK s3Client.send(GetObjectCommand) | Bytes streamed server-side; NO presigned URL ever produced (undici fetch(presignedUrl) throws bad port) |
| Header safety | sanitizeFilenameForHeader() | Strips control chars, normalizes NFD→NFC, replaces non-ASCII — prevents HPE_INVALID_HEADER_TOKEN |
| File streaming | streamFile() → Buffer → res.send() | No client-side redirect; file proxied through NestJS via the S3 SDK |
| Content-Disposition | inline (preview) vs attachment (download) | Controlled by ?download=true query param |
| X-Frame-Options | res.removeHeader('X-Frame-Options') | Removed to allow iframe PDF embedding in the clinical portal |
Design choice: File retrieval does NOT use NestJS streaming (pipe()) — the full buffer is loaded into memory and sent via res.send(). This is acceptable for clinical documents (typically < 50 MB per file) and avoids partial-read error handling complexity. If documents routinely exceed 20 MB, switch to res.pipe(stream) with the MinIO Node.js stream API.
Data Flow
Clinician previews a lab report
Business outcome: Doctor clicks a lab report PDF in the patient chart; it appears in an inline PDF viewer within 1 second without triggering a file download.
Technical mechanism:
- Browser sends
GET /api/v1/documents/{uuid}(no?download=true). GatewayAuthGuardvalidatesX-API-Key+X-Gateway-*headers; populatesreq.user.RbacEnforcementInterceptorsees@RbacExempt()onDocumentsController— skips role check.DocumentsController.getDocument()builds anAccessContextfromreq.user.id,req.user.role,req.user.organizationId,req.ip,req.headers['user-agent'].storageService.streamFile(id, context)validates the attachment exists ANDattachment.organizationId === context.organizationId(404/403 if not), then fetches the object vias3Client.send(GetObjectCommand)— returns{ buffer, metadata }in one call. No presigned URL is produced.sanitizeFilenameForHeader(metadata.filename)— normalizes filename forContent-Disposition.- Response:
Content-Disposition: inline; filename="..."→ PDF viewer renders in-browser.X-Frame-Optionsheader removed to allow iframe embedding.
Admin creates a document template (MANAGE gate)
POST /api/v1/document-templates → DocumentTemplatesController.create() → @RequireRole(...DOCUMENT_MANAGE_ROLES) — DOCTOR/CLINIC_ADMIN/SUPER_ADMIN only. STAFF/RECEPTIONIST/NURSE receive 403 from RbacEnforcementInterceptor. Template stored in ddx_api_ai.documentTemplate with status: 'DRAFT'.
Staff generates a patient document (GENERATE gate)
POST /api/v1/generated-documents → GeneratedDocumentsController.generate() → @RequireRole(...DOCUMENT_GENERATE_ROLES) — same role set as VIEW (DOCTOR/STAFF/RECEPTIONIST/CLINIC_ADMIN/SUPER_ADMIN). Nurse and receptionist CAN generate documents, but cannot create or review templates.
Doctor reviews and approves a generated document (REVIEW gate)
PATCH /api/v1/generated-documents/:id → GeneratedDocumentsMutationsService → @RequireRole(...DOCUMENT_REVIEW_ROLES) — DOCTOR/CLINIC_ADMIN/SUPER_ADMIN. STAFF/RECEPTIONIST/NURSE cannot approve or reject generated documents.
Implicated Code
ddx-api/src/documents/documents-core/documents.controller.ts:104—@RbacExempt()at class level — all file retrieval routes bypass RBAC interceptor; org-scoped access enforced by StorageService insteadddx-api/src/documents/documents-core/documents.controller.ts:56—sanitizeFilenameForHeader()— strips control chars + non-ASCII to preventHPE_INVALID_HEADER_TOKENerror in Node.js HTTP parserddx-api/src/documents/documents-core/documents.controller.ts—AccessContextconstruction — assemblesuserId,userRole,organizationId,ipAddress,userAgentfor per-file access validationddx-api/src/documents/documents-core/documents.controller.ts:289—storageService.streamFile(id, context)— enforces org-scoped ownership + fetches bytes vias3Client.send(GetObjectCommand)in one call (no presigned URL)ddx-api/src/documents/documents-core/documents.controller.ts—res.removeHeader('X-Frame-Options')— allows PDF iframe embedding in clinical portalddx-api/src/documents/documents-core/documents-hub.service.ts:30—DocumentsHubService— org-scoped document stats, recent documents, metadata, and AI-powered translationddx-api/src/documents/documents-core/documents-hub.service.ts:70—getStats()—Promise.all([aggregate, groupBy, recentCount])onAttachmentmodel scoped byorganizationId + status: 'AVAILABLE'ddx-api/src/documents/document-generation/rbac.ts:3—DOCUMENT_MANAGE_ROLES—[DOCTOR, CLINIC_ADMIN, SUPER_ADMIN]ddx-api/src/documents/document-generation/rbac.ts:9—DOCUMENT_VIEW_ROLES—[DOCTOR, STAFF, RECEPTIONIST, CLINIC_ADMIN, SUPER_ADMIN]ddx-api/src/documents/document-generation/rbac.ts:17—DOCUMENT_GENERATE_ROLES = DOCUMENT_VIEW_ROLES— explicit alias (any viewer can generate)ddx-api/src/documents/document-generation/rbac.ts:19—DOCUMENT_REVIEW_ROLES—[DOCTOR, CLINIC_ADMIN, SUPER_ADMIN](STAFF/RECEPTIONIST excluded from approval)ddx-api/src/documents/document-generation/document-templates.controller.ts:44—@RequireRole(...DOCUMENT_MANAGE_ROLES)oncreate()— template authoring gated to DOCTOR+ddx-api/src/documents/document-generation/generated-documents.controller.ts:56—@RequireRole(...DOCUMENT_GENERATE_ROLES)ongenerate()— all viewer roles can trigger generation
Operational Notes
@RbacExempt()does NOT skipGatewayAuthGuard: The JWT is always validated.@RbacExempt()only bypasses theRbacEnforcementInterceptor. Unauthenticated requests still receive 401. Never confuse@RbacExempt()with@Public()(which skips auth entirely).- Organization isolation at storage layer:
StorageService.streamFile()validatesattachment.organizationId === req.user.organizationId. Cross-org document access is impossible through this endpoint, even for SUPER_ADMIN. This is a deliberate security boundary. - MinIO bucket naming: Attachments are stored in
{clinic-slug}-documentsor{clinic-slug}-attachmentsdepending onAttachmentType. The storage service resolves bucket from attachment type — callers never specify buckets directly. - No presigned URLs in the retrieval path: bytes are fetched server-side via
s3Client.send(GetObjectCommand)and proxied through NestJS. A prior path generated a MinIO presigned URL andfetch()-ed it from the Node process — undici throwsTypeError: fetch failed/bad port, 500-storming every preview/download (condor media-gallery, 2026-06-30). Never reintroduce presign+fetch for retrieval (project memoryfeedback_minio_stream_not_presign_fetch). X-Frame-Optionsremoval: Theres.removeHeader('X-Frame-Options')call atdocuments.controller.ts:314is intentional — the clinical portal uses<iframe>to display PDFs inline. Removing it introduces clickjacking risk for the document URL if clients navigate to it directly; ensure CSPframe-ancestorsis set in the portal's Next.js response headers.- Filename sanitization in header:
sanitizeFilenameForHeader()normalizes NFD → NFC, strips control characters, and replaces non-ASCII characters. This prevents Node.js HTTP parser from throwingHPE_INVALID_HEADER_TOKENwhen serving German/Arabic patient document filenames. Callers should never omit thefilenamequery param for downloads — fallback is the literal string'document'. - NURSE role gap: NURSE is not in
DOCUMENT_MANAGE_ROLESorDOCUMENT_REVIEW_ROLES. Nurses can view and generate documents but cannot create templates or approve generated content. If clinic workflow requires nurse review, addUserRole.NURSEtoDOCUMENT_REVIEW_ROLESinrbac.ts:19.
Related Topics
- Document Management — Document lifecycle, MinIO buckets, attachment types
- Doxing — OCR and AI-powered document analysis pipeline
- PDF Engine — Playwright/WeasyPrint PDF generation consumed by DocumentExportService