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¶
- Separate CMKs per environment:
alias/lastvet-staging-imaging,alias/lastvet-prod-imaging. - Least privilege on
kms:Decrypt: grant only to the ECS task role that generates presigned URLs after consent checks. Do not grantkms:Decryptto broad operator roles, CI roles, or human users except break-glass with separate audit. - S3 bucket policy complements IAM: deny
s3:*except from expected principals; enforceaws:SecureTransport; require SSE-KMS onPutObject. - 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).
- 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.
6. Provider delivery: presigned GET (consent-gated)¶
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:
- 5-minute TTL (not 15)
- Audit at issuance so the veteran sees the access event even if download completes after revoke
- 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.
7. Consent boundary: not document_share¶
Imaging is clinical-adjacent. It must not use grant-free document_share links.
- Allowlist remains
service_recordsonly (documentShareAllowlist.ts) - Imaging uses
category = veteran_uploads→ rejected by default at link creation and delivery - Required tests:
imaging_dicomandimaging_pdfartifacts must return 400 onPOST /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:
- Sum active bytes for the veteran via
DocumentsRepository.sumActiveBytesForVeteran - Read the declared object size from the client init request
- 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
- [ ]
DocumentsRepositoryS3 metadata methods; no directdocumentsqueries - [ ] Tests:
document_sharerejectsimaging_dicom/imaging_pdf - [ ] Enable
VETERAN_UPLOAD_STORAGE_CAP_BYTES=5368709120on 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 |