From 20303ce11c6ad75eeb0b31899fff525c111ac1dd Mon Sep 17 00:00:00 2001 From: dashprotocol <46986265+dashprotocol@users.noreply.github.com> Date: Thu, 28 May 2026 03:06:38 -0400 Subject: [PATCH] feat(H-008): add S3 Terraform scaffolding and D2 runbook --- docs/runbook/s3-iam.md | 237 ++++++++++++++++++++++++++++ infra/terraform/.gitignore | 12 ++ infra/terraform/.terraform.lock.hcl | 25 +++ infra/terraform/iam.tf | 42 +++++ infra/terraform/main.tf | 35 ++++ infra/terraform/outputs.tf | 14 ++ infra/terraform/s3.tf | 71 +++++++++ server/.env.example | 9 ++ 8 files changed, 445 insertions(+) create mode 100644 docs/runbook/s3-iam.md create mode 100644 infra/terraform/.gitignore create mode 100644 infra/terraform/.terraform.lock.hcl create mode 100644 infra/terraform/iam.tf create mode 100644 infra/terraform/main.tf create mode 100644 infra/terraform/outputs.tf create mode 100644 infra/terraform/s3.tf diff --git a/docs/runbook/s3-iam.md b/docs/runbook/s3-iam.md new file mode 100644 index 0000000..48b7393 --- /dev/null +++ b/docs/runbook/s3-iam.md @@ -0,0 +1,237 @@ +# Runbook: S3 Storage + IAM Setup + +## Purpose +Provision the Havenhold S3 bucket and IAM credentials for document storage using the Terraform module in `infra/terraform/`. This runbook covers two phases: + +- **D0 phase (H-008):** Terraform module written and reviewed; `terraform plan` verified. No AWS resources created yet. +- **D2 phase (H-016/H-017):** `terraform apply` executed, access key generated, credentials injected into the host environment. Run immediately before deploying the upload flow. + +## Security Handling +- This runbook is git-tracked and must stay sanitised — never commit real AWS credentials, account IDs, or key IDs. +- AWS access keys are generated once at D2 and injected into the host via the systemd `EnvironmentFile` or `/opt/havenhold/server/.env`. They must never be committed to the repo. +- Store generated access keys in approved secret storage (e.g. 1Password) immediately after creation — the secret value cannot be retrieved again from AWS. +- Rotate access keys if any exposure is suspected; see [Access Key Rotation](#access-key-rotation) below. + +## Prerequisites +- AWS account with IAM `AdministratorAccess` (or equivalent) for the operator running Terraform. +- Terraform v1.x installed locally (`terraform -version`). +- AWS CLI v2 installed and configured (`aws --version`, `aws sts get-caller-identity`). +- `var.env` value decided (`prod` by default). + +--- + +## D0 Steps: Verify Scaffolding + +No AWS resources are created at D0. Verify the module plans cleanly: + +```bash +cd infra/terraform + +# Download provider +terraform init + +# Plan against your AWS account — review output, expect no errors +AWS_REGION=us-east-1 terraform plan -var="env=prod" +``` + +Expected plan output: 7 resources to add (bucket, public access block, ownership controls, encryption config, bucket policy, IAM user, IAM user policy). No destroy or modify actions. + +--- + +## D2 Steps: Apply and Configure + +Run these steps immediately before deploying the upload flow (`H-016`/`H-017`). + +### Step 1: Apply Terraform + +```bash +cd infra/terraform +terraform init # if not already done +terraform plan -var="env=prod" # review before applying +terraform apply -var="env=prod" +``` + +Capture the outputs: + +```bash +terraform output +# bucket_name = "havenhold-prod-" +# bucket_arn = "arn:aws:s3:::havenhold-prod-" +# iam_user_arn = "arn:aws:iam:::user/havenhold-api" +``` + +### Step 2: Generate access key + +```bash +aws iam create-access-key --user-name havenhold-api +``` + +The response contains `AccessKeyId` and `SecretAccessKey`. **Save both values to your secret store immediately** — the secret cannot be retrieved again. + +### Step 3: Inject credentials into host environment + +SSH to the app host and add the variables to the server env file: + +```bash +KEY_PATH=/path/to/key.pem +STATIC_IP= +ADMIN_USER=adminuser + +ssh -i "$KEY_PATH" "$ADMIN_USER"@"$STATIC_IP" +sudo nano /opt/havenhold/server/.env +``` + +Add or update these lines (values from Steps 1 and 2): + +```dotenv +AWS_REGION=us-east-1 +AWS_ACCESS_KEY_ID= +AWS_SECRET_ACCESS_KEY= +AWS_S3_BUCKET= +``` + +Restart the app process so it picks up the new variables: + +```bash +# Confirm the correct unit name before restarting — the API service unit is +# defined as part of the host deploy process, not a specific ticket. +sudo systemctl list-units --type=service | grep havenhold +sudo systemctl restart +``` + +--- + +## D2 Verification Checklist + +```bash +export BUCKET= + +# 1. Public access block (all four must be true) +aws s3api get-public-access-block --bucket "$BUCKET" + +# 2. Encryption (SSEAlgorithm must be aws:kms) +aws s3api get-bucket-encryption --bucket "$BUCKET" + +# 3. Bucket policy (DenyNonHTTPS statement present) +aws s3api get-bucket-policy --bucket "$BUCKET" | python3 -m json.tool + +# 4. IAM policy attached and scoped correctly +aws iam get-user-policy --user-name havenhold-api --policy-name HavenholdS3Access \ + | python3 -m json.tool + +# 5. Active access key exists +aws iam list-access-keys --user-name havenhold-api + +# 6. Positive test — put/get/delete under uploads/ (run with API credentials) +export API_KEY= +export API_SECRET= + +echo "d2-smoke" | AWS_ACCESS_KEY_ID=$API_KEY AWS_SECRET_ACCESS_KEY=$API_SECRET \ + aws s3 cp - s3://$BUCKET/uploads/d2-smoke.txt +AWS_ACCESS_KEY_ID=$API_KEY AWS_SECRET_ACCESS_KEY=$API_SECRET \ + aws s3api head-object --bucket "$BUCKET" --key uploads/d2-smoke.txt +AWS_ACCESS_KEY_ID=$API_KEY AWS_SECRET_ACCESS_KEY=$API_SECRET \ + aws s3 rm s3://$BUCKET/uploads/d2-smoke.txt + +# 7. Negative test — account discovery (expect AccessDenied) +AWS_ACCESS_KEY_ID=$API_KEY AWS_SECRET_ACCESS_KEY=$API_SECRET \ + aws s3api list-buckets + +# 8. Negative test — out-of-prefix write (expect AccessDenied) +echo "should-fail" | AWS_ACCESS_KEY_ID=$API_KEY AWS_SECRET_ACCESS_KEY=$API_SECRET \ + aws s3 cp - s3://$BUCKET/misc/should-fail.txt +``` + +--- + +## Access Key Rotation + +Rotate the access key without any downtime: + +```bash +# 1. Create a new key +aws iam create-access-key --user-name havenhold-api +# Save new AccessKeyId and SecretAccessKey to secret store + +# 2. Update /opt/havenhold/server/.env on the host with the new values +# 3. Restart the app (confirm unit name: systemctl list-units --type=service | grep havenhold) +sudo systemctl restart + +# 4. Verify the app is healthy, then delete the old key +aws iam delete-access-key --user-name havenhold-api --access-key-id + +# 5. Confirm only one active key remains +aws iam list-access-keys --user-name havenhold-api +``` + +--- + +## Rollback / Teardown + +> **Warning:** `terraform destroy` fails on a non-empty bucket. There are two safe paths. + +**Path A — bucket is empty (or at D2 before any uploads):** + +```bash +cd infra/terraform +terraform destroy -var="env=prod" +``` + +**Path B — bucket contains objects:** + +```bash +# Step 1: Confirm you intend to delete all objects +aws s3 ls s3://$BUCKET --recursive # review what will be lost + +# Step 2: Empty the bucket +aws s3 rm s3://$BUCKET --recursive + +# Step 3: Destroy +cd infra/terraform +terraform destroy -var="env=prod" +``` + +Alternatively, set `force_destroy = true` in `s3.tf`, run `terraform apply`, then `terraform destroy`. Either way, confirm data disposition before proceeding — object deletion is permanent. + +--- + +## State Backend Migration (D2) + +After `terraform apply` succeeds and the bucket exists, migrate Terraform state into the bucket: + +1. Add a backend block to `infra/terraform/main.tf`, replacing `backend "local" {}`: + +```hcl +backend "s3" { + bucket = "havenhold-prod-" + key = "terraform/havenhold.tfstate" + region = "us-east-1" +} +``` + +2. Migrate local state: + +```bash +cd infra/terraform +terraform init -migrate-state +``` + +3. Confirm local `terraform.tfstate` is now empty/stale and the S3 object exists: + +```bash +aws s3 ls s3://havenhold-prod-/terraform/ +``` + +4. Delete the local state file (it is now in S3): + +```bash +rm terraform.tfstate +``` + +--- + +## Notes + +- **CMK upgrade path:** If key-level CloudTrail granularity, explicit key disablement, or custom rotation schedules become necessary, create a KMS CMK, update `sse_algorithm` to reference its ARN, and add `kms:GenerateDataKey` + `kms:Decrypt` to the IAM policy scoped to that key ARN. +- **Signed URLs:** Direct public S3 URLs are always blocked. All file access goes through server-generated presigned URLs, implemented in `H-017`. +- **Versioning:** Not enabled. Revisit if point-in-time object recovery becomes a requirement. diff --git a/infra/terraform/.gitignore b/infra/terraform/.gitignore new file mode 100644 index 0000000..03b4a9a --- /dev/null +++ b/infra/terraform/.gitignore @@ -0,0 +1,12 @@ +# State files — may contain resource IDs and sensitive values; never commit +terraform.tfstate +terraform.tfstate.backup +terraform.tfstate.*.backup + +# Terraform working directory (downloaded providers) +.terraform/ + +# Variable override files — local dev only, never commit +*.tfvars +*.tfvars.json +!*.tfvars.example diff --git a/infra/terraform/.terraform.lock.hcl b/infra/terraform/.terraform.lock.hcl new file mode 100644 index 0000000..cdc1668 --- /dev/null +++ b/infra/terraform/.terraform.lock.hcl @@ -0,0 +1,25 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/aws" { + version = "5.100.0" + constraints = "~> 5.0" + hashes = [ + "h1:Ijt7pOlB7Tr7maGQIqtsLFbl7pSMIj06TVdkoSBcYOw=", + "zh:054b8dd49f0549c9a7cc27d159e45327b7b65cf404da5e5a20da154b90b8a644", + "zh:0b97bf8d5e03d15d83cc40b0530a1f84b459354939ba6f135a0086c20ebbe6b2", + "zh:1589a2266af699cbd5d80737a0fe02e54ec9cf2ca54e7e00ac51c7359056f274", + "zh:6330766f1d85f01ae6ea90d1b214b8b74cc8c1badc4696b165b36ddd4cc15f7b", + "zh:7c8c2e30d8e55291b86fcb64bdf6c25489d538688545eb48fd74ad622e5d3862", + "zh:99b1003bd9bd32ee323544da897148f46a527f622dc3971af63ea3e251596342", + "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425", + "zh:9f8b909d3ec50ade83c8062290378b1ec553edef6a447c56dadc01a99f4eaa93", + "zh:aaef921ff9aabaf8b1869a86d692ebd24fbd4e12c21205034bb679b9caf883a2", + "zh:ac882313207aba00dd5a76dbd572a0ddc818bb9cbf5c9d61b28fe30efaec951e", + "zh:bb64e8aff37becab373a1a0cc1080990785304141af42ed6aa3dd4913b000421", + "zh:dfe495f6621df5540d9c92ad40b8067376350b005c637ea6efac5dc15028add4", + "zh:f0ddf0eaf052766cfe09dea8200a946519f653c384ab4336e2a4a64fdd6310e9", + "zh:f1b7e684f4c7ae1eed272b6de7d2049bb87a0275cb04dbb7cda6636f600699c9", + "zh:ff461571e3f233699bf690db319dfe46aec75e58726636a0d97dd9ac6e32fb70", + ] +} diff --git a/infra/terraform/iam.tf b/infra/terraform/iam.tf new file mode 100644 index 0000000..bf9cffc --- /dev/null +++ b/infra/terraform/iam.tf @@ -0,0 +1,42 @@ +resource "aws_iam_user" "api" { + name = "havenhold-api" + path = "/" +} + +resource "aws_iam_user_policy" "api_s3" { + name = "HavenholdS3Access" + user = aws_iam_user.api.name + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Sid = "BucketList" + Effect = "Allow" + Action = ["s3:ListBucket"] + Resource = aws_s3_bucket.documents.arn + Condition = { + StringLike = { + "s3:prefix" = ["uploads/*", "exports/*", "temp/*"] + } + } + }, + { + Sid = "ObjectReadWrite" + Effect = "Allow" + Action = [ + "s3:GetObject", + "s3:PutObject", + "s3:DeleteObject", + "s3:AbortMultipartUpload" + ] + # Explicitly scoped to approved prefixes — not a wildcard on the whole bucket. + Resource = [ + "${aws_s3_bucket.documents.arn}/uploads/*", + "${aws_s3_bucket.documents.arn}/exports/*", + "${aws_s3_bucket.documents.arn}/temp/*" + ] + } + ] + }) +} diff --git a/infra/terraform/main.tf b/infra/terraform/main.tf new file mode 100644 index 0000000..eeb6b2e --- /dev/null +++ b/infra/terraform/main.tf @@ -0,0 +1,35 @@ +terraform { + required_version = ">= 1.0" + + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 5.0" + } + } + + # Local backend until D2, when the S3 bucket exists. + # Migration sequence at D2: + # 1. Add backend "s3" {} block (bucket, key, region). + # 2. Run: terraform init -migrate-state + # Skipping step 2 creates a second independent state file and causes drift. + backend "local" {} +} + +variable "env" { + description = "Deployment environment (e.g. prod, staging)" + type = string + default = "prod" +} + +variable "aws_region" { + description = "AWS region for all resources" + type = string + default = "us-east-1" +} + +provider "aws" { + region = var.aws_region +} + +data "aws_caller_identity" "current" {} diff --git a/infra/terraform/outputs.tf b/infra/terraform/outputs.tf new file mode 100644 index 0000000..d5d0635 --- /dev/null +++ b/infra/terraform/outputs.tf @@ -0,0 +1,14 @@ +output "bucket_name" { + description = "S3 bucket name — use as AWS_S3_BUCKET in the server environment" + value = aws_s3_bucket.documents.id +} + +output "bucket_arn" { + description = "S3 bucket ARN" + value = aws_s3_bucket.documents.arn +} + +output "iam_user_arn" { + description = "ARN of the havenhold-api IAM user" + value = aws_iam_user.api.arn +} diff --git a/infra/terraform/s3.tf b/infra/terraform/s3.tf new file mode 100644 index 0000000..873fcd3 --- /dev/null +++ b/infra/terraform/s3.tf @@ -0,0 +1,71 @@ +locals { + bucket_name = "havenhold-${var.env}-${data.aws_caller_identity.current.account_id}" +} + +resource "aws_s3_bucket" "documents" { + bucket = local.bucket_name + + # force_destroy allows terraform destroy on a non-empty bucket. + # Keep false in normal operation — set to true only immediately before a + # deliberate destroy, then apply before destroying. + force_destroy = false +} + +resource "aws_s3_bucket_public_access_block" "documents" { + bucket = aws_s3_bucket.documents.id + + block_public_acls = true + ignore_public_acls = true + block_public_policy = true + restrict_public_buckets = true +} + +resource "aws_s3_bucket_ownership_controls" "documents" { + bucket = aws_s3_bucket.documents.id + + rule { + object_ownership = "BucketOwnerEnforced" + } +} + +resource "aws_s3_bucket_server_side_encryption_configuration" "documents" { + bucket = aws_s3_bucket.documents.id + + rule { + apply_server_side_encryption_by_default { + # AWS-managed key (aws/s3). No key policy configuration required. + # Upgrade to a customer-managed key (CMK) if key-level audit granularity + # or explicit disable/rotation control becomes necessary. + sse_algorithm = "aws:kms" + } + bucket_key_enabled = true + } +} + +resource "aws_s3_bucket_policy" "documents" { + bucket = aws_s3_bucket.documents.id + + # block_public_policy must be applied before a bucket policy can be set. + depends_on = [aws_s3_bucket_public_access_block.documents] + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Sid = "DenyNonHTTPS" + Effect = "Deny" + Principal = "*" + Action = "s3:*" + Resource = [ + aws_s3_bucket.documents.arn, + "${aws_s3_bucket.documents.arn}/*" + ] + Condition = { + Bool = { + "aws:SecureTransport" = "false" + } + } + } + ] + }) +} diff --git a/server/.env.example b/server/.env.example index ccc6a00..e57dac8 100644 --- a/server/.env.example +++ b/server/.env.example @@ -14,3 +14,12 @@ INTEGRATIONS_ENABLED=false # false = no outbound integration calls (default). Re # Deployment environment and CORS (production host only — omit or set NODE_ENV=development locally) NODE_ENV=production CORS_ORIGIN=https:// + +# AWS S3 object storage — provisioned at D2 (H-008/H-016) +# Run `terraform output` in infra/terraform/ after applying to get BUCKET_NAME. +# Generate access key with: aws iam create-access-key --user-name havenhold-api +# Never commit real values. See docs/runbook/s3-iam.md. +AWS_REGION=us-east-1 +AWS_ACCESS_KEY_ID= +AWS_SECRET_ACCESS_KEY= +AWS_S3_BUCKET=