Skip to content

Security Posture: Veteran Imaging Storage (S3)

LastVet / Last 1 Enterprises LLC
Date: 2026-07-24
Status: Approved plan. Not yet built. Accurate as written.
Home: Phase 2 veteran upload architecture (imaging)
Companion: SECURITY_POSTURE_DATABASE_ACCESS.md, CONSENT_AND_DOCUMENT_CATEGORIES.md


1. Why this exists

Imaging (DICOM studies, imaging PDFs) is the first veteran upload class stored in S3 with presigned multipart direct upload. It deliberately does not use the DD-214 vault encryption model. This document states the encryption delta precisely, names the load-bearing controls, and documents consent exceptions (presigned GET revocation gap) so they are not tribal knowledge.

Audiences: internal engineering, Hogan Lovells, provider diligence, SOC 2 evidence.


2. Scope

In scope (v1) Out of scope (v1)
DICOM / ZIP studies, imaging PDFs DICOM viewer
documents.category = veteran_uploads document_share (grant-free links)
document_type in imaging_dicom, imaging_pdf Server-side format conversion
Presigned S3 multipart upload (iOS) App-layer AES-256-GCM on object bytes
Consent-gated provider download via presigned GET Annotation, imaging-specific share UI

Part 2: Imaging is not a Part 2 category by default. It does not route to sud unless explicitly scoped and classified as SUD content (not expected for DICOM/PDF imaging uploads).

Query path: All reads/writes to documents go through DocumentsRepository with RLS request context. No exceptions.


3. Encryption posture: imaging vs DD-214

3.1 Two storage classes

Asset class Upload path At rest App-visible layers
DD-214 vault (service_records) API receives bytes → EFS EFS encryption + app-layer AES-256-GCM (writeEncryptedDocument, server-held envelope key) Two
Imaging (veteran_uploads) Client → S3 presigned multipart (API never sees object bytes on upload) SSE-KMS (CMK per environment) One

3.2 The security delta (precise)

DD-214 app-layer AES-256-GCM protects against an over-broad S3-style grant: even a principal with full bucket read access sees ciphertext, not plaintext, unless it also holds the LastVet envelope key (server-side, not in IAM).

Imaging SSE-KMS does not provide that separation: if the same principal holds both s3:GetObject on the object and kms:Decrypt on the CMK, it reads plaintext. There is no second LastVet-controlled encryption layer on the object.

Imaging therefore relies on correct IAM + KMS key policy, not on ciphertext-at-rest independent of cloud credentials.

3.3 Rejected alternatives (decided 2026-07-24)

Option Verdict
(a) SSE-KMS only Ship. Matches direct-upload architecture.
(b) Client-side encrypt before upload Reject. Provider delivery requires decrypt capability; devolves into server-held keys without fixing upload flow.
(c) Staging prefix + API/Lambda re-encrypt Reject. Double handling, latency, and cost on 200–500 MB objects; negates presigned upload benefit.

3.4 AWS BAA

Amazon S3 is a HIPAA-eligible service. LastVet operates in AWS account 525382240840 (us-west-2) under an executed AWS Business Associate Agreement. Imaging buckets use the same BAA coverage as RDS, EFS, and ECS, provided buckets are private, encrypted, and access-logged. No new vendor BAA is required for S3.


4. Load-bearing controls (KMS key policy)

Because imaging plaintext is reachable when S3 read and KMS decrypt co-exist on one principal, the KMS key policy is load-bearing, not decorative.

4.1 Principles

  1. Separate CMKs per environment: alias/lastvet-staging-imaging, alias/lastvet-prod-imaging.
  2. Least privilege on kms:Decrypt: grant only to the ECS task role that generates presigned URLs after consent checks. Do not grant kms:Decrypt to broad operator roles, CI roles, or human users except break-glass with separate audit.
  3. S3 bucket policy complements IAM: deny s3:* except from expected principals; enforce aws:SecureTransport; require SSE-KMS on PutObject.
  4. Presigned URLs are capability tokens: they embed SigV4 credentials for both S3 GET and KMS decrypt for the object key. Short TTL limits blast radius (see §6).
  5. No public access. Block Public Access on; no bucket ACL grants.

4.2 Example key policy intent (illustrative)

Allow:
  - ECS task role (lastvet-{env}-api): kms:Decrypt, kms:GenerateDataKey*, kms:DescribeKey
    (via IAM role policy + key policy resource match)

Deny (or omit):
  - Account root, power-user roles, deployment roles without explicit imaging need
  - Cross-environment principals (staging role must not decrypt prod CMK)

Review trigger: any IAM change that adds s3:GetObject on lastvet-*-imaging-* must be paired with explicit KMS policy review.


5. Infrastructure topology

Environment Bucket (pattern) CMK
Staging lastvet-staging-imaging-{account}-usw2 staging CMK
Production lastvet-prod-imaging-{account}-usw2 prod CMK
  • Object key layout: {veteranId}/{documentId}/original (+ optional {documentId}/preview.jpg)
  • CORS: configured for direct iOS multipart upload
  • Lifecycle: Standard → Standard-IA after 90 days on original/ prefix
  • Incomplete multipart abort: 7 days (recommended)

Metadata in Postgres (documents via DocumentsRepository): storage_backend = 's3', storage_path = S3 key, file_size, optional preview key in metadata.


Provider access to imaging is not grant-free. It follows the existing consent engine + DocumentsRepository path. Delivery mechanism: presigned S3 GET issued by the API after a successful consent check.

This is a consent and audit decision, not a performance optimization.

6.1 Issuance requirements

Requirement Value / behavior
Expiry 5 minutes (300 seconds). Long enough to start a large download; short enough that the revocation gap is negligible. Not 15 minutes.
Precondition Active grant + consent check passed + provider RLS context set + document row loaded via DocumentsRepository
Audit at issuance Every presigned GET must write consent_audit_log when the URL is issued, not only when S3 serves bytes. Without this, imaging fetches are invisible in the veteran Access History.
Suggested action imaging_download_url_issued (or read with document id in metadata if action enum is constrained)
categories_accessed ['veteran_uploads'] (or document-specific category if narrowed later)
accessor_type provider
was_permitted TRUE

The veteran's Access History must show that a provider obtained download capability for their imaging, even if the provider never completes the download.

6.2 Revocation gap (bounded exception to "revoke means revoke")

Verified A4 (2026-07-23): grant revocation cuts off new consent checks immediately. Provider portal and API refuse further reads once the grant is inactive.

Exception for imaging presigned GET: a URL issued before revocation remains valid until expiry (max 5 minutes). The holder can complete the download without a live grant check because the URL is a signed capability.

Moment Behavior
Revoke grant API stops issuing new presigned URLs immediately
URL already issued, not yet expired Download may still succeed (revocation gap)
After URL expiry Download fails; new request requires live grant

This is a real, bounded exception. It must be disclosed in counsel review and veteran-facing privacy copy where provider download is described. Mitigations:

  1. 5-minute TTL (not 15)
  2. Audit at issuance so the veteran sees the access event even if download completes after revoke
  3. No imaging on document_share (grant-free path eliminated)

Do not quietly undercut A4: instant revocation applies to authorization to obtain access, not to in-flight signed URLs already handed out.


Imaging is clinical-adjacent. It must not use grant-free document_share links.

  • Allowlist remains service_records only (documentShareAllowlist.ts)
  • Imaging uses category = veteran_uploads → rejected by default at link creation and delivery
  • Required tests: imaging_dicom and imaging_pdf artifacts must return 400 on POST /veteran/me/secure-links

8. Per-veteran storage cap

Setting Value
Env var VETERAN_UPLOAD_STORAGE_CAP_BYTES
Default when imaging enabled 5 GB (5368709120)
Phase 1 (DD-214 only) 0 (disabled)

8.1 Enforcement (load-bearing)

Cap checks run at multipart init, not only at complete.

At init the API must:

  1. Sum active bytes for the veteran via DocumentsRepository.sumActiveBytesForVeteran
  2. Read the declared object size from the client init request
  3. Reject if bytes_in_use + declared_size > cap

Why: without declared-size check, a veteran at 4.6 GB can init a 500 MB study and land at 5.1 GB after upload completes.

Return STORAGE_CAP_EXCEEDED / 413 with remaining headroom when rejected.


9. Cost order-of-magnitude (us-west-2)

Assumption: 1.5 GB average retained imaging per active veteran (mix of 200–500 MB studies).

Cohort Stored ~$/month (Standard → IA lifecycle)
100 veterans 150 GB ~$2.50
1,000 veterans 1.5 TB ~$25

KMS CMK (~$1/env/month) and request charges are negligible at pilot scale. Budget ~$30/year at 100 veterans, ~$300/year at 1,000 veterans for imaging storage alone.


10. Build checklist (engineering)

  • [ ] Staging S3 bucket + CMK + IAM + CORS + lifecycle
  • [ ] Multipart init/complete API; cap at init with declared size
  • [ ] Pending-upload reaper (S3 multipart absent → metadata.upload_status = aborted; cap reservation released)
  • [ ] Presigned GET helper: 300s expiry, audit INSERT on issuance
  • [ ] DocumentsRepository S3 metadata methods; no direct documents queries
  • [ ] Tests: document_share rejects imaging_dicom / imaging_pdf
  • [ ] Enable VETERAN_UPLOAD_STORAGE_CAP_BYTES=5368709120 on staging, then prod at imaging launch
  • [ ] iOS: resumable multipart, Wi‑Fi-only default >50 MB, surfaced errors

11. Status

Item State
Encryption model (SSE-KMS only) Approved 2026-07-24
KMS key policy as load-bearing control Documented
Presigned GET 5 min + audit at issuance Approved 2026-07-24
Revocation gap documented This document
5 GB cap with declared-size check at init Approved 2026-07-24
S3 infrastructure Not built
Imaging upload/download API Not built