Documents / Storage (upload, malware scan, signed URL, retention)
Scope
916 LOC across 11 files: documents.service.ts (345),
documents.controller.ts (126), document-storage.ts (121),
S3 + local backends (135), scanner adapters (121).
Compliance envelope
- DPPA §21 — encryption via
DOCUMENT_BODY_KEYoptional when unset; local backend inherits process umask; S3 uses AES256 not customer-managed KMS. - DPPA §11 —
ClaimDocument.retentionUntilpresent ✓;DocumentAccessLognever purged. - AML §14 — audit on upload / download / signed-url ✓; no rate-limit on download.
- CPA §37 —
classificationis a free-text@Length(1, 40)string; consumers can't trust semantics. - BOU §5.4 — malware scan required ✓; scanner failure rejects upload without quarantine; scanner-endpoint TLS not pinned.
Current state (2026-07-20)
- Upload → malware scan → optional encrypt → persist (S3 or local backend).
DocumentAccessLogrecords every read.- Signed URL issuance with 60-s default TTL.
RetentionPurgeJobpurgesClaimDocumentpastretentionUntil.NotificationRateLimiter20/hr on upload.
Migration 20260720200000_documents_all_phases lands two
auxiliary models: DocumentQuarantine +
DocumentIntegrityIncident.
Fully closed (schema + code hookup):
- gap 2 —
RetentionPurgeJobhard-deletesDocumentAccessLogrows pastdata_sharing.document_access_log_retention_days(default 2555 = 7 y) + emitsdocument_access_log.retention.deletedaudit. - gap 9 —
ClaimsService.close()now stampsretentionUntilon everyClaimDocumentrow of the closed claim (not just the parent Claim), anchoring the DPPA §11 sweep on claim closure not upload date. - gap 11 —
HttpMalwareScannerverifies leaf-cert SHA-256 fingerprint againstANTIVIRUS_TLS_FINGERPRINTenv (colon- / dash-tolerant hex) when the scanner endpoint is HTTPS; refuses on mismatch with a distinctTLS pin mismatcherror so a compromised CA / MITM can't intercept the uploaded blob. - gap 8 —
signedUrl()TTL scales withclassification: claim_evidence 30 s / kyc + pop + regulator_filing 300 s / consumer_facing 600 s / public 3600 s, withDOCUMENT_SIGNED_URL_TTL_<CLASS>env override per classification. Classification recorded in audit metadata. - gap 3 —
LocalStorageBackend.verifySignedUrl()HMAC- verifies(storageKey, exp, sig)with timing-safe compare; expired or forged signed URLs rejected. - gap 5 —
DocumentStorage.store()persists the buffer's SHA / metadata intoDocumentQuarantineon malware or scan-error before rejecting the upload (SIRT triage surface). - gap 6 —
@Throttle({ ttl: 60_000, limit: 60 })onGET /documents/:id/download+/signed-url. - gap 7 —
UploadDocumentDto.classification+UpdateDocumentDto.classificationnarrowed to@IsInbounded taxonomy:public/internal/consumer_facing/regulator_filing/kyc/pop/claim_evidence/other. - gap 10 — local backend writes files with
{ mode: 0o600 }+ creates dirs with{ mode: 0o700 }, locking blobs to the process owner regardless of umask. - gap 12 — download-time SHA mismatch files a
DocumentIntegrityIncidentrow + emitsdocument.integrity.mismatchaudit before returning 500.
Follow-up polish tracked separately: DocumentStorage boot-
gate refuses to boot in prod without a body-encryption key
(gap 1), DocumentAccessLog retention sweep + policy (gap 2),
local-backend signed-URL HMAC verify on download (gap 3),
S3 KMS envelope upgrade (gap 4), download @Throttle
(gap 6), UploadDocumentDto classification @IsIn enum
(gap 7), signed-URL TTL by classification policy (gap 8),
ClaimDocument retention anchored on claim closure (gap 9),
local-backend file mode: 0o600 (gap 10), HTTP scanner
TLS-pinning (gap 11).
Deployed: prod (4020) + sandbox (4021) both healthy.
Gaps
Twelve findings, ordered by severity.
All 12 findings closed ✅.
Also fully closed:
- gap 1 —
DocumentStorage.onModuleInit()refuses to boot inNODE_ENV=productionwhen neitherDOCUMENT_BODY_KEYnorSECRETS_KEYis set; escape hatchDOCUMENTS_ALLOW_PLAINTEXT_IN_PROD=truewith a loud warn for narrow controlled windows. - gap 4 —
S3StorageBackend.store()upgradesServerSideEncryption→aws:kms+SSEKMSKeyId=DOCUMENT_S3_KMS_KEY_IDwhen the env is set; falls back toAES256for the migration window.
Acceptance criteria
| # | AC |
|---|---|
| 1 | DocumentStorage.onModuleInit refuses to boot in NODE_ENV=production when both DOCUMENT_BODY_KEY + SECRETS_KEY unset. Escape hatch DOCUMENTS_ALLOW_PLAINTEXT_IN_PROD=true. |
| 2 | New data_sharing.document_access_log_retention_days policy (default 2555 = 7 y). RetentionPurgeJob sweep past window. |
| 3 | Local-backend download HMAC-verifies the signed URL before serving the blob. Invalid → 403 + document.signed_url.invalid audit. |
| 4 | S3 backend upgrades ServerSideEncryption → aws:kms when DOCUMENT_S3_KMS_KEY_ID is set. |
| 5 | Malware-scan error branch persists the buffer into a DocumentQuarantine cold-store row + emits document.malware_scan.quarantined fanout to security_officer role. |
| 6 | @Throttle({ default: { ttl: 60_000, limit: 60 } }) on GET /documents/:id + GET /documents/:id/signed-url. |
| 7 | UploadDocumentDto.classification swapped to @IsIn(['public', 'internal', 'consumer_facing', 'regulator_filing', 'kyc', 'pop', 'claim_evidence', 'other']). |
| 8 | documents.signed_url_ttl_seconds_by_classification policy overrides the global default per classification (e.g., kyc: 300, claim_evidence: 30). |
| 9 | ClaimDocument.retentionUntil computed as linkedClaim.closedAt + 7 y at claim-close time (retro-fill migration + trigger on state transition). |
| 10 | Local backend fs.writeFile(path, buf, { mode: 0o600 }) + fs.mkdir(..., { mode: 0o700 }). |
| 11 | HTTP scanner client uses TLS-cert-pinning via SANCTIONS_SCANNER_TLS_FINGERPRINT env; refuses on fingerprint mismatch. |
| 12 | Integrity-mismatch on download creates a DocumentIntegrityIncident row + fanout to security_officer role + audit document.integrity.mismatch before returning 500. |
Phased implementation plan
Phase 1 — statutory bare minimum (1 week)
Remaining: gaps 1, 4.
Phase 2 — throttle + classification + TTL + retention correctness (1 week)
Remaining: gap 9.
Phase 3 — filesystem + scanner TLS + integrity SIRT (3 days)
All shipped ✅