# ACCESS Core AWS Infrastructure (Terraform) Deployable infrastructure for the MetaFlow ACCESS Core Database, organized as reusable modules plus three environment roots. ## Layout ``` infra/ modules/ # reusable, environment-agnostic vpc/ # public + private subnets, IGW, route tables (no NAT) endpoints/ # interface VPC endpoints (s3/secretsmanager/kms/sqs) kms/ # PHI field key + pseudonymization key (auto-rotation) database/ # RDS PostgreSQL 16 in private subnets storage/ # S3 artifact bucket (SSE-KMS or AES256, versioning opt-in) queue/ # webhook SQS queue + DLQ with redrive policy ecs/ # Fargate cluster/service, ALB, ECR, IAM roles, alarms alerting/ # SNS topic for alarm notifications (+ optional email subs) environments/ # one root module per environment dev/ # single AZ, t3.micro RDS (unencrypted), SSE-S3, force_destroy staging/ # KMS everywhere, SSE-KMS + versioned S3, fixed fleet of 2 prod/ # multi-AZ, t3.medium Multi-AZ RDS, autoscaling 1-4, deletion protection ``` Environment differences follow `docs/architecture/AWS_Cost_Estimate.md`: dev is deliberately cheap and disposable (synthetic data only); staging/prod enable the full PHI path via KMS. ## State backend bootstrap (one-time, before first `terraform init`) The S3 + DynamoDB backend must exist before Terraform can use it: ```bash aws s3api create-bucket \ --bucket metaflow-access-core-terraform-state \ --create-bucket-configuration LocationConstraint=us-east-1 # bucket key for SSE (optional but recommended) KEY_ID=$(aws kms create-key --description "Terraform state encryption" \ --query 'KeyMetadata.Arn' --output text) aws s3api put-bucket-encryption --bucket metaflow-access-core-terraform-state \ --server-side-encryption-configuration "{\"Rule\":{\"ApplyServerSideEncryptionByDefault\":{\"SSEAlgorithm\":\"aws:kms\",\"KMSMasterKeyId\":\"$KEY_ID\"}}}" # versioning (protects state from accidental overwrite) aws s3api put-bucket-versioning --bucket metaflow-access-core-terraform-state \ --versioning-configuration Status=Enabled aws dynamodb create-table \ --table-name metaflow-access-core-terraform-locks \ --attribute-definitions AttributeName=LockID,AttributeType=S \ --key-schema AttributeName=LockID,KeyType=HASH \ --billing-mode PAY_PER_REQUEST ``` ## Usage ```bash cd infra/environments/dev terraform init # first run: configures the S3 backend (prompts for state migration only on moves) terraform plan terraform apply ``` - **dev**: `phi_local_key` in `terraform.tfvars` matches the key derived by `access_core/settings/dev.py`; dev runs local (non-KMS) PHI encryption. - **staging/prod**: the app runs `settings.base` with `PHI_KMS_KEY_ID`, `ALLOWED_HOSTS`, and `AWS_S3_BUCKET` injected into the task definition from module outputs; no local key is ever set. `PHI_PSEUDONYMIZATION_KEY_ID` is intentionally not injected yet — nothing reads it until the pseudonymization runtime service lands (the KMS key/alias are pre-provisioned). - **staging/prod** allowlist hosts as `*.elb.amazonaws.com` plus an optional `-var="public_domain=..."`; set that once a Route53 CNAME fronts the ALB. - **staging/prod** alarms (HTTP 5xx bursts, webhook queue depth, and permanent Healthie GraphQL fetch failures — e.g. an expired API key) notify the SNS topic from the `alerting` module; pass `-var='alert_emails=["ops@example.com"]'` to subscribe inboxes (each address must click a one-time confirmation link). - **prod** requires an explicit `-var="image_tag="`; there is no default on purpose so a deploy can never accidentally roll `latest`. ## Pre-go-live checklist (operator-supplied credentials) Terraform provisions only what it can generate (`django-secret-key`, RDS password). Before real traffic, add the following to each environment: - **Healthie**: API key + webhook signing secret — extend the `-app-secrets` JSON and map them in the task-definition `secrets` block in `modules/ecs/main.tf` (or pass non-sensitive ones via `extra_environment`). - **CMS ACCESS API**: client ID / secret for sandbox (staging) or production (prod), plus `CMS_API_BASE_URL`. Until set, the app falls back to the CMS sandbox with auth skipped — never acceptable in prod. ## Secrets Each environment creates two Secrets Manager secrets (randomly generated): | Secret | Keys inside | Consumed by | |---|---|---| | `access-core--rds-password` | `password` | RDS + task def env (`POSTGRES_PASSWORD`) | | `access-core--app-secrets` | `django-secret-key` | task def env (`DJANGO_SECRET_KEY`) | (`` is dev/staging/prod; the full name also carries the project prefix, e.g. `access-core-staging-app-secrets`.) Rotating either secret and then re-deploying the ECS service (new task definition revision) is the supported rotation path; RDS picks up new password versions on restart. ## KMS keys The `kms` module creates two keys per environment with automatic rotation: - `alias/-phi-field` — app-level AES-256-GCM envelope encryption (`PHI_KMS_KEY_ID`). Rotation is free for the `enc:v1` format because each ciphertext records its own key version. - `alias/-pseudonymization` — case_id HMAC for the offshore review boundary (`PHI_PSEUDONYMIZATION_KEY_ID`, see `docs/security/PHI_Pseudonymization_Design.md`). Because the key policy is restrictive, access requires BOTH the key-policy grant and the matching identity-based permission on the task role (the ecs module attaches `kms:Encrypt/Decrypt` to the task role). The key policy grants: - admin (`kms:*`) to the account root by default; pass `-var="admin_arn=..."` (module input) to pin a specific IAM principal. - `kms:Encrypt/Decrypt/DescribeKey` to the ECS task role — wired via the required `app_principal_arns` module input (the environment roots build the ARN from the fixed `-fargate-task` role name). - RDS service principal (`CreateGrant/GenerateDataKey*/Decrypt`) on the field key only, so SSE-KMS storage encryption works. ## Validation without AWS credentials ```bash terraform -chdir=infra/environments/ init -backend=false terraform -chdir=infra/environments/ validate ```