05-documentswave: W2filled14 citations

Document ACL — Access Control for Clinical Documents

Audiences: developer, internal

Document ACL — Access Control for Clinical Documents

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 via organizationId scoping 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:

  1. Role gates for document management actionsDOCUMENT_MANAGE_ROLES, DOCUMENT_VIEW_ROLES, DOCUMENT_GENERATE_ROLES, DOCUMENT_REVIEW_ROLES arrays applied via @RequireRole() on template and generated-document endpoints. These gates control who can create/publish templates, generate new documents, and approve/review content.
  2. Storage-layer access control for file retrievalDocumentsController is @RbacExempt(). The global GatewayAuthGuard still 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's organizationId, streams the bytes directly via the S3 SDK (s3Client.send(GetObjectCommand)), and sanitizeFilenameForHeader() 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 with bad 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; StorageService enforces 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.organizationId field (Prisma ddx_api_main). Files stored in MinIO buckets per clinic slug (e.g. ddx-hamburg-clinic-documents). StorageService.AccessContext carries userId, userRole, organizationId, ipAddress, userAgent for audit logging.

Architecture

Document access has two layers.

Layer A: Role Gates (RBAC Interceptor — applies to management endpoints):

Endpoint / controllerRequired roles
DocumentTemplatesControllerDOCUMENT_MANAGE_ROLES / DOCUMENT_VIEW_ROLES
GeneratedDocumentsControllerDOCUMENT_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):

RouteMechanismNotes
DocumentsController@RbacExempt() (class-level)
GET :id@RbacExempt inherited
GET :id/preview, GET :id/downloadStorageService.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):

ArrayValue
DOCUMENT_MANAGE_ROLES[DOCTOR, CLINIC_ADMIN, SUPER_ADMIN]
DOCUMENT_VIEW_ROLES[DOCTOR, STAFF, RECEPTIONIST, CLINIC_ADMIN, SUPER_ADMIN]
DOCUMENT_GENERATE_ROLESDOCUMENT_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

LayerTechnologyNotes
Role arraysUserRole[] constants in rbac.tsImported by both documents.controller.ts and document-templates.controller.ts — single source of truth
RBAC bypass@RbacExempt() at controller class leveldocuments.controller.ts:104 — applied once; all methods inherit
Auth enforcementGatewayAuthGuard (APP_GUARD)Still fires on every request; @RbacExempt() only bypasses RbacEnforcementInterceptor
Storage access checkStorageService.streamFile(id, AccessContext)Validates organizationId match; returns { buffer, metadata } in one call
MinIO file accessS3 SDK s3Client.send(GetObjectCommand)Bytes streamed server-side; NO presigned URL ever produced (undici fetch(presignedUrl) throws bad port)
Header safetysanitizeFilenameForHeader()Strips control chars, normalizes NFD→NFC, replaces non-ASCII — prevents HPE_INVALID_HEADER_TOKEN
File streamingstreamFile()Bufferres.send()No client-side redirect; file proxied through NestJS via the S3 SDK
Content-Dispositioninline (preview) vs attachment (download)Controlled by ?download=true query param
X-Frame-Optionsres.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:

  1. Browser sends GET /api/v1/documents/{uuid} (no ?download=true).
  2. GatewayAuthGuard validates X-API-Key + X-Gateway-* headers; populates req.user.
  3. RbacEnforcementInterceptor sees @RbacExempt() on DocumentsController — skips role check.
  4. DocumentsController.getDocument() builds an AccessContext from req.user.id, req.user.role, req.user.organizationId, req.ip, req.headers['user-agent'].
  5. storageService.streamFile(id, context) validates the attachment exists AND attachment.organizationId === context.organizationId (404/403 if not), then fetches the object via s3Client.send(GetObjectCommand) — returns { buffer, metadata } in one call. No presigned URL is produced.
  6. sanitizeFilenameForHeader(metadata.filename) — normalizes filename for Content-Disposition.
  7. Response: Content-Disposition: inline; filename="..." → PDF viewer renders in-browser. X-Frame-Options header removed to allow iframe embedding.

Admin creates a document template (MANAGE gate)

POST /api/v1/document-templatesDocumentTemplatesController.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-documentsGeneratedDocumentsController.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/:idGeneratedDocumentsMutationsService@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 instead
  • ddx-api/src/documents/documents-core/documents.controller.ts:56sanitizeFilenameForHeader() — strips control chars + non-ASCII to prevent HPE_INVALID_HEADER_TOKEN error in Node.js HTTP parser
  • ddx-api/src/documents/documents-core/documents.controller.tsAccessContext construction — assembles userId, userRole, organizationId, ipAddress, userAgent for per-file access validation
  • ddx-api/src/documents/documents-core/documents.controller.ts:289storageService.streamFile(id, context) — enforces org-scoped ownership + fetches bytes via s3Client.send(GetObjectCommand) in one call (no presigned URL)
  • ddx-api/src/documents/documents-core/documents.controller.tsres.removeHeader('X-Frame-Options') — allows PDF iframe embedding in clinical portal
  • ddx-api/src/documents/documents-core/documents-hub.service.ts:30DocumentsHubService — org-scoped document stats, recent documents, metadata, and AI-powered translation
  • ddx-api/src/documents/documents-core/documents-hub.service.ts:70getStats()Promise.all([aggregate, groupBy, recentCount]) on Attachment model scoped by organizationId + status: 'AVAILABLE'
  • ddx-api/src/documents/document-generation/rbac.ts:3DOCUMENT_MANAGE_ROLES[DOCTOR, CLINIC_ADMIN, SUPER_ADMIN]
  • ddx-api/src/documents/document-generation/rbac.ts:9DOCUMENT_VIEW_ROLES[DOCTOR, STAFF, RECEPTIONIST, CLINIC_ADMIN, SUPER_ADMIN]
  • ddx-api/src/documents/document-generation/rbac.ts:17DOCUMENT_GENERATE_ROLES = DOCUMENT_VIEW_ROLES — explicit alias (any viewer can generate)
  • ddx-api/src/documents/document-generation/rbac.ts:19DOCUMENT_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) on create() — template authoring gated to DOCTOR+
  • ddx-api/src/documents/document-generation/generated-documents.controller.ts:56@RequireRole(...DOCUMENT_GENERATE_ROLES) on generate() — all viewer roles can trigger generation

Operational Notes

  • @RbacExempt() does NOT skip GatewayAuthGuard: The JWT is always validated. @RbacExempt() only bypasses the RbacEnforcementInterceptor. Unauthenticated requests still receive 401. Never confuse @RbacExempt() with @Public() (which skips auth entirely).
  • Organization isolation at storage layer: StorageService.streamFile() validates attachment.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}-documents or {clinic-slug}-attachments depending on AttachmentType. 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 and fetch()-ed it from the Node process — undici throws TypeError: fetch failed / bad port, 500-storming every preview/download (condor media-gallery, 2026-06-30). Never reintroduce presign+fetch for retrieval (project memory feedback_minio_stream_not_presign_fetch).
  • X-Frame-Options removal: The res.removeHeader('X-Frame-Options') call at documents.controller.ts:314 is 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 CSP frame-ancestors is 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 throwing HPE_INVALID_HEADER_TOKEN when serving German/Arabic patient document filenames. Callers should never omit the filename query param for downloads — fallback is the literal string 'document'.
  • NURSE role gap: NURSE is not in DOCUMENT_MANAGE_ROLES or DOCUMENT_REVIEW_ROLES. Nurses can view and generate documents but cannot create templates or approve generated content. If clinic workflow requires nurse review, add UserRole.NURSE to DOCUMENT_REVIEW_ROLES in rbac.ts:19.
  • 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