Skip to content

Commit b2abf99

Browse files
voidstackloopclaude
andcommitted
feat(infra): AWS CDK stack for the imaging S3 bucket + CloudFront, and env-var slots for both
Adds infra/imaging-cdk/ — a CDK app provisioning exactly what docs/IMAGING.md's "Required AWS resources" table already specified: a private, versioned, SSE-KMS-encrypted S3 bucket; a customer-managed KMS CMK with rotation; a CloudFront distribution fronting the bucket via Origin Access Control (not the legacy OAI the table rules out) with a trusted signing key group and HTTPS-only viewer policy; a separate access-log bucket; and a least-privilege IAM role for whatever compute runs server/ (S3 get/put/delete + the two specific KMS grants, nothing broader). See infra/imaging-cdk/README.md for the full deploy flow. The CloudFront signing key pair's private half is generated offline with openssl per the README and never touches CDK/CloudFormation -- only the public key's file path is read at synth time. Verified locally with `npm install` + `tsc --noEmit` + `cdk synth` against a fake account/region and a throwaway test keypair (both discarded); actual `cdk deploy` needs a real AWS account and hasn't been run from here. Also wires the env vars docs/IMAGING.md already documented (and this stack's own outputs feed) into compose.dev.yml, compose.prod.yml, and .env.production.example: IMAGING_S3_*/IMAGING_CLOUDFRONT_* (opt-in -- absent by default, so local-filesystem storage keeps working unchanged) and AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY/AWS_SESSION_TOKEN for the AWS SDK's default credential chain S3ImagingObjectStore already relies on. Noted in both compose files and the CDK README that real AWS compute should prefer the stack's IAM role over static keys; the env vars are the fallback for a plain `docker compose up` host. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 999fcdf commit b2abf99

12 files changed

Lines changed: 1259 additions & 0 deletions

.env.production.example

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,39 @@ OIDC_ADMIN_CLIENT_ID=modelforge-admin
1313
IMAGING_ENCRYPTION_KEY=
1414
METRICS_TOKEN=replace-with-a-long-random-bearer-token
1515

16+
# Optional: S3 imaging storage instead of the local-filesystem volume above.
17+
# Leave all of these blank to stay on local-filesystem storage. Provision
18+
# the bucket/KMS key/CloudFront distribution with infra/imaging-cdk/ (see
19+
# its README) rather than by hand — its stack outputs map directly onto
20+
# these four. All three of IMAGING_S3_BUCKET/KMS_KEY_ID/REGION must be set
21+
# together, or none at all; see docs/IMAGING.md's "Required AWS resources".
22+
IMAGING_S3_BUCKET=
23+
IMAGING_S3_KMS_KEY_ID=
24+
IMAGING_S3_REGION=
25+
IMAGING_S3_KEY_PREFIX=
26+
27+
# Optional, and only meaningful once IMAGING_S3_BUCKET is set above: fronts
28+
# the bucket with signed CloudFront URLs instead of streaming pixel data
29+
# through this server process. All three required together.
30+
# IMAGING_CLOUDFRONT_KEY_PAIR_ID: from infra/imaging-cdk's
31+
# ImagingCloudFrontKeyPairId output.
32+
# IMAGING_CLOUDFRONT_PRIVATE_KEY: base64 of the PEM private key generated
33+
# per infra/imaging-cdk/README.md step 1 — this value never comes from
34+
# CDK output; it's generated offline and never leaves this file.
35+
IMAGING_CLOUDFRONT_DOMAIN=
36+
IMAGING_CLOUDFRONT_KEY_PAIR_ID=
37+
IMAGING_CLOUDFRONT_PRIVATE_KEY=
38+
39+
# Only needed when IMAGING_S3_BUCKET is set above (the AWS SDK's default
40+
# credential chain picks these up automatically). On real AWS compute
41+
# (ECS/EC2/EKS), prefer an attached IAM role instead — see
42+
# infra/imaging-cdk/README.md's server task role output — and leave these
43+
# three blank. Static keys are the fallback for a host with no AWS-native
44+
# identity to inherit from.
45+
AWS_ACCESS_KEY_ID=
46+
AWS_SECRET_ACCESS_KEY=
47+
AWS_SESSION_TOKEN=
48+
1649
# The included admin container is plain HTTP on this local port. Terminate
1750
# TLS in a real ingress/reverse proxy before exposing it to users.
1851
ADMIN_HTTPS_PROXY_PORT=8080

.gitignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,11 @@ graphify-out/
2323
.env.production
2424
/models/
2525
/secrets/
26+
infra/imaging-cdk/cdk.out/
27+
# The CloudFront signing key pair generated per infra/imaging-cdk/README.md
28+
# — the private half must never be committed; the public half doesn't need
29+
# to be either (each real deployment generates its own).
30+
infra/imaging-cdk/*.pem
2631

2732
# Personal AI-assistant tooling state — machine-specific absolute paths and
2833
# per-developer config, not portable across machines or meant to be shared.

compose.dev.yml

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,31 @@ services:
6767
IMAGING_LOCAL_ROOT: /var/lib/modelforge/imaging
6868
# Exactly 32 bytes after base64 decoding. Development only.
6969
IMAGING_ENCRYPTION_KEY: MDEyMzQ1Njc4OTAxMjM0NTY3ODkwMTIzNDU2Nzg5MDE= # gitleaks:allow — sequential digits, dev-only placeholder
70+
# Opt-in S3 imaging storage against a real bucket (see
71+
# infra/imaging-cdk/README.md) — unset by default, so this container
72+
# stays on local-filesystem storage above unless you export real
73+
# values in your own shell before `docker compose up`. Setting
74+
# IMAGING_S3_BUCKET/KMS_KEY_ID/REGION (all three, or none) switches
75+
# config.ts's storage-mode detection to S3; the CLOUDFRONT_* trio is
76+
# separately optional on top of that. Never put real values directly
77+
# in this file — compose only reads them from the host environment.
78+
IMAGING_S3_BUCKET: ${IMAGING_S3_BUCKET:-}
79+
IMAGING_S3_KMS_KEY_ID: ${IMAGING_S3_KMS_KEY_ID:-}
80+
IMAGING_S3_REGION: ${IMAGING_S3_REGION:-}
81+
IMAGING_S3_KEY_PREFIX: ${IMAGING_S3_KEY_PREFIX:-}
82+
IMAGING_CLOUDFRONT_DOMAIN: ${IMAGING_CLOUDFRONT_DOMAIN:-}
83+
IMAGING_CLOUDFRONT_KEY_PAIR_ID: ${IMAGING_CLOUDFRONT_KEY_PAIR_ID:-}
84+
IMAGING_CLOUDFRONT_PRIVATE_KEY: ${IMAGING_CLOUDFRONT_PRIVATE_KEY:-}
85+
# The AWS SDK's default credential chain (S3ImagingObjectStore's
86+
# `new S3Client({ region })`, no explicit keys in code) picks these up
87+
# automatically when set. Only needed in S3 mode; harmless empty
88+
# otherwise. On real AWS compute (ECS/EC2/EKS), prefer an attached
89+
# IAM role instead (see infra/imaging-cdk/README.md's server task
90+
# role) and leave these three unset — static keys are the fallback
91+
# for a plain `docker compose up` host with no AWS-native identity.
92+
AWS_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID:-}
93+
AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY:-}
94+
AWS_SESSION_TOKEN: ${AWS_SESSION_TOKEN:-}
7095
RATE_LIMIT_MAX: "1000"
7196
ports:
7297
- "${SERVER_PORT:-4000}:4000"

compose.prod.yml

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,25 @@ services:
5050
TRUST_PROXY: "1"
5151
IMAGING_LOCAL_ROOT: /var/lib/modelforge/imaging
5252
IMAGING_ENCRYPTION_KEY: ${IMAGING_ENCRYPTION_KEY:?set IMAGING_ENCRYPTION_KEY}
53+
# Opt-in S3 imaging storage (see infra/imaging-cdk/ and
54+
# docs/IMAGING.md's "Required AWS resources") — leave unset to stay
55+
# on the local-filesystem volume above. Setting IMAGING_S3_BUCKET/
56+
# KMS_KEY_ID/REGION switches config.ts's storage-mode detection to
57+
# S3; CLOUDFRONT_* is separately optional on top of that.
58+
IMAGING_S3_BUCKET: ${IMAGING_S3_BUCKET:-}
59+
IMAGING_S3_KMS_KEY_ID: ${IMAGING_S3_KMS_KEY_ID:-}
60+
IMAGING_S3_REGION: ${IMAGING_S3_REGION:-}
61+
IMAGING_S3_KEY_PREFIX: ${IMAGING_S3_KEY_PREFIX:-}
62+
IMAGING_CLOUDFRONT_DOMAIN: ${IMAGING_CLOUDFRONT_DOMAIN:-}
63+
IMAGING_CLOUDFRONT_KEY_PAIR_ID: ${IMAGING_CLOUDFRONT_KEY_PAIR_ID:-}
64+
IMAGING_CLOUDFRONT_PRIVATE_KEY: ${IMAGING_CLOUDFRONT_PRIVATE_KEY:-}
65+
# Only needed in S3 mode. On real AWS compute, prefer an attached IAM
66+
# role instead (infra/imaging-cdk/README.md's server task role) and
67+
# leave these unset — static keys are the fallback for a host with no
68+
# AWS-native identity to inherit from.
69+
AWS_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID:-}
70+
AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY:-}
71+
AWS_SESSION_TOKEN: ${AWS_SESSION_TOKEN:-}
5372
POOL_MAX: ${POOL_MAX:-20}
5473
RATE_LIMIT_MAX: ${RATE_LIMIT_MAX:-300}
5574
RATE_LIMIT_WINDOW_MS: ${RATE_LIMIT_WINDOW_MS:-60000}

docs/IMAGING.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -404,6 +404,13 @@ DICOM values.
404404

405405
### Required AWS resources
406406

407+
`infra/imaging-cdk/` provisions everything in the table below as one CDK
408+
stack — see that directory's README for the signing-key-generation step,
409+
deploy instructions, and how its outputs map onto the env vars in
410+
"Configuration" below. The table itself is still the source of truth for
411+
*what's* required; the stack is a direct translation of it, not an
412+
independent design.
413+
407414
| Resource | Requirement |
408415
|---|---|
409416
| S3 bucket | Block Public Access fully on; versioning on; default encryption SSE-KMS with the CMK below; bucket policy allowing **only** the CloudFront distribution via Origin Access Control |

infra/imaging-cdk/README.md

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
# Imaging CDK stack
2+
3+
Provisions the AWS resources `docs/IMAGING.md`'s "Required AWS resources"
4+
table specifies for `server/`'s `S3ImagingObjectStore`: a private,
5+
SSE-KMS-encrypted S3 bucket; a customer-managed KMS key; a CloudFront
6+
distribution fronting the bucket via Origin Access Control with a trusted
7+
signing key group; and a least-privilege IAM role for whatever compute runs
8+
`server/`. Read that table before changing anything here — this stack is a
9+
direct translation of it, not an independent design.
10+
11+
**What this stack does *not* do**: it does not deploy `server/` itself (no
12+
ECS service/task definition, no compute). It hands back
13+
`ImagingServerTaskRoleArn` as an output — attach that role to whichever
14+
compute you actually run `server/` on (ECS task role, EC2 instance profile,
15+
etc.). It also does not touch anything outside this one CloudFormation
16+
stack — no VPC, no networking, no other ModelForge infrastructure.
17+
18+
**Not run against a real AWS account from this repository** — `npx cdk
19+
synth` was used locally to confirm the stack synthesizes correctly (a fake
20+
account/region and a throwaway test keypair, both discarded); `cdk diff`
21+
and `cdk deploy` need your real AWS credentials and account, and are yours
22+
to run.
23+
24+
## 1. Generate the signing key pair (offline, outside CDK)
25+
26+
The private half of the CloudFront signing key must never be generated by,
27+
or pass through, CDK/CloudFormation — it goes straight from `openssl` to
28+
the server's own secret storage (`IMAGING_CLOUDFRONT_PRIVATE_KEY`), and
29+
CDK only ever sees the public half.
30+
31+
```bash
32+
openssl genrsa -out imaging-signing-key.pem 2048
33+
openssl rsa -pubout -in imaging-signing-key.pem -out imaging-signing-key.pub.pem
34+
```
35+
36+
- `imaging-signing-key.pub.pem` — passed to this stack (see below). Safe to
37+
keep around; it's public.
38+
- `imaging-signing-key.pem` — the private half. Base64-encode it
39+
(`base64 -w0 imaging-signing-key.pem`) and set that as the server's
40+
`IMAGING_CLOUDFRONT_PRIVATE_KEY`. **Never commit either file** — the
41+
repo's `.gitignore` already excludes `infra/imaging-cdk/*.pem`, so
42+
generating them directly in this directory (as the commands above do) is
43+
covered automatically; confirm with `git check-ignore -v` if you generate
44+
them elsewhere instead.
45+
46+
## 2. Install and bootstrap
47+
48+
```bash
49+
npm ci
50+
npx cdk bootstrap aws://<account-id>/<region> # once per account+region
51+
```
52+
53+
## 3. Review and deploy
54+
55+
```bash
56+
npx cdk diff \
57+
-c signingPublicKeyPath=/absolute/path/to/imaging-signing-key.pub.pem \
58+
-c bucketNamePrefix=modelforge-imaging-prod
59+
60+
npx cdk deploy \
61+
-c signingPublicKeyPath=/absolute/path/to/imaging-signing-key.pub.pem \
62+
-c bucketNamePrefix=modelforge-imaging-prod
63+
```
64+
65+
`bucketNamePrefix` defaults to `modelforge-imaging`; the stack appends
66+
`-<account>-<region>` to keep the final bucket name globally unique.
67+
`signingPublicKeyPath` defaults to `./imaging-signing-key.pub.pem` (relative
68+
to wherever you run the CDK CLI from).
69+
70+
## 4. Map the outputs onto server config
71+
72+
`cdk deploy` prints these as stack outputs; `docs/IMAGING.md`'s
73+
"Configuration" section names the exact env vars each one fills:
74+
75+
| Stack output | Env var |
76+
|---|---|
77+
| `ImagingS3Bucket` | `IMAGING_S3_BUCKET` |
78+
| `ImagingS3KmsKeyId` | `IMAGING_S3_KMS_KEY_ID` |
79+
| `ImagingS3Region` | `IMAGING_S3_REGION` |
80+
| `ImagingCloudFrontDomain` | `IMAGING_CLOUDFRONT_DOMAIN` |
81+
| `ImagingCloudFrontKeyPairId` | `IMAGING_CLOUDFRONT_KEY_PAIR_ID` |
82+
| — (step 1's private key, base64) | `IMAGING_CLOUDFRONT_PRIVATE_KEY` |
83+
| `ImagingServerTaskRoleArn` | attach to server/'s compute; not an env var |
84+
85+
`IMAGING_S3_KEY_PREFIX` isn't a stack output — it's your own choice of
86+
object-key namespace within the bucket, not an AWS resource.
87+
88+
See `.env.production.example` at the repo root for where these land in a
89+
real deployment's environment file.
90+
91+
## Teardown
92+
93+
`cdk destroy` removes the CloudFront distribution and IAM role, but the S3
94+
bucket, log bucket, and KMS key all use `RemovalPolicy.RETAIN` deliberately
95+
(this stack holds PHI-bearing imaging data and its encryption key) — delete
96+
those by hand, deliberately, only after confirming what's actually in the
97+
bucket. A KMS key scheduled for deletion has a mandatory 7–30 day waiting
98+
window before AWS actually deletes it.
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import * as path from "node:path";
2+
import { App } from "aws-cdk-lib";
3+
import { ImagingStack } from "../lib/imaging-stack.js";
4+
5+
const app = new App();
6+
7+
const signingPublicKeyPath = app.node.tryGetContext("signingPublicKeyPath") ?? path.resolve(process.cwd(), "imaging-signing-key.pub.pem");
8+
const bucketNamePrefix = app.node.tryGetContext("bucketNamePrefix") ?? "modelforge-imaging";
9+
10+
new ImagingStack(app, "ModelForgeImagingStack", {
11+
env: {
12+
account: process.env.CDK_DEFAULT_ACCOUNT,
13+
region: process.env.CDK_DEFAULT_REGION,
14+
},
15+
signingPublicKeyPath,
16+
bucketNamePrefix,
17+
});

infra/imaging-cdk/cdk.json

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
{
2+
"app": "npx tsx bin/imaging-cdk.ts",
3+
"watch": {
4+
"include": ["bin/**", "lib/**"],
5+
"exclude": ["README.md", "cdk*.json", "**/*.d.ts", "**/*.js", "dist/**", "node_modules/**"]
6+
},
7+
"context": {
8+
"@aws-cdk/aws-lambda:recognizeLayerVersion": true,
9+
"@aws-cdk/core:checkSecretUsage": true,
10+
"@aws-cdk/core:target-partitions": ["aws", "aws-cn"],
11+
"@aws-cdk-containers/ecs-service-extensions:enableDefaultLogDriver": true,
12+
"@aws-cdk/aws-ec2:uniqueImdsv2TemplateName": true,
13+
"@aws-cdk/aws-iam:minimizePolicies": true,
14+
"@aws-cdk/core:validateSnapshotRemovalPolicy": true,
15+
"@aws-cdk/aws-s3:createDefaultLoggingPolicy": true,
16+
"@aws-cdk/aws-s3:serverAccessLogsUseBucketPolicy": true,
17+
"@aws-cdk/core:enablePartitionLiterals": true,
18+
"@aws-cdk/aws-events:eventsTargetQueueSameAccount": true,
19+
"@aws-cdk/aws-iam:standardizedServicePrincipals": true,
20+
"@aws-cdk/aws-s3:keepNotificationInImportedBucket": false,
21+
"@aws-cdk/aws-ec2:restrictDefaultSecurityGroup": true,
22+
"@aws-cdk/aws-apigateway:disableCloudWatchRole": true
23+
}
24+
}

0 commit comments

Comments
 (0)