From 25c2e84e8fcb4c2b18331ce4f9e33e5503a30182 Mon Sep 17 00:00:00 2001 From: Adam Moussa Date: Fri, 28 Aug 2026 19:12:34 -0400 Subject: [PATCH] feat(terraform): adopt live deployment roles safely Add import-only dev and staging state with least-privilege HCP authentication and plan safety guards. --- .github/workflows/ci-terraform.yaml | 50 ++++ .github/workflows/deploy.yml | 87 +++++-- .gitignore | 11 + QUALITY_GATES.md | 3 + scripts/check-terraform-import-plan.py | 74 ++++++ scripts/deploy-api-tf.sh | 113 +++++++++ scripts/governance-check.sh | 4 + scripts/test-terraform-import-plan-check.py | 69 ++++++ terraform/.terraform.lock.hcl | 53 ++++ terraform/README.md | 170 +++++++++++++ terraform/acm.tf | 37 +++ terraform/bootstrap/.terraform.lock.hcl | 29 +++ terraform/bootstrap/iam.tf | 228 +++++++++++++++++ terraform/bootstrap/live_workspace_roles.tf | 215 ++++++++++++++++ terraform/bootstrap/locals.tf | 43 ++++ terraform/bootstrap/outputs.tf | 29 +++ terraform/bootstrap/providers.tf | 11 + terraform/bootstrap/terraform.tfvars.example | 15 ++ terraform/bootstrap/variables.tf | 88 +++++++ terraform/bootstrap/versions.tf | 18 ++ terraform/elastic_beanstalk.tf | 234 ++++++++++++++++++ terraform/iam_github_deploy.tf | 145 +++++++++++ terraform/iam_runtime.tf | 69 ++++++ terraform/live/README.md | 73 ++++++ terraform/live/dev/.terraform.lock.hcl | 26 ++ terraform/live/dev/imports.tf | 9 + terraform/live/dev/main.tf | 42 ++++ terraform/live/dev/outputs.tf | 20 ++ terraform/live/dev/providers.tf | 3 + terraform/live/dev/versions.tf | 19 ++ terraform/live/modules/deploy-role/main.tf | 173 +++++++++++++ terraform/live/modules/deploy-role/outputs.tf | 14 ++ .../live/modules/deploy-role/variables.tf | 62 +++++ .../modules/environment-inventory/main.tf | 59 +++++ .../modules/environment-inventory/outputs.tf | 24 ++ .../environment-inventory/variables.tf | 59 +++++ terraform/live/staging/.terraform.lock.hcl | 26 ++ terraform/live/staging/imports.tf | 9 + terraform/live/staging/main.tf | 42 ++++ terraform/live/staging/outputs.tf | 20 ++ terraform/live/staging/providers.tf | 3 + terraform/live/staging/versions.tf | 19 ++ terraform/locals.tf | 48 ++++ terraform/outputs.tf | 49 ++++ terraform/providers.tf | 12 + terraform/rds.tf | 66 +++++ terraform/secrets.tf | 49 ++++ terraform/security_groups.tf | 93 +++++++ terraform/terraform.tfvars.example | 25 ++ terraform/variables.tf | 195 +++++++++++++++ terraform/versions.tf | 22 ++ 51 files changed, 3039 insertions(+), 17 deletions(-) create mode 100644 .github/workflows/ci-terraform.yaml create mode 100644 scripts/check-terraform-import-plan.py create mode 100644 scripts/deploy-api-tf.sh create mode 100644 scripts/test-terraform-import-plan-check.py create mode 100644 terraform/.terraform.lock.hcl create mode 100644 terraform/README.md create mode 100644 terraform/acm.tf create mode 100644 terraform/bootstrap/.terraform.lock.hcl create mode 100644 terraform/bootstrap/iam.tf create mode 100644 terraform/bootstrap/live_workspace_roles.tf create mode 100644 terraform/bootstrap/locals.tf create mode 100644 terraform/bootstrap/outputs.tf create mode 100644 terraform/bootstrap/providers.tf create mode 100644 terraform/bootstrap/terraform.tfvars.example create mode 100644 terraform/bootstrap/variables.tf create mode 100644 terraform/bootstrap/versions.tf create mode 100644 terraform/elastic_beanstalk.tf create mode 100644 terraform/iam_github_deploy.tf create mode 100644 terraform/iam_runtime.tf create mode 100644 terraform/live/README.md create mode 100644 terraform/live/dev/.terraform.lock.hcl create mode 100644 terraform/live/dev/imports.tf create mode 100644 terraform/live/dev/main.tf create mode 100644 terraform/live/dev/outputs.tf create mode 100644 terraform/live/dev/providers.tf create mode 100644 terraform/live/dev/versions.tf create mode 100644 terraform/live/modules/deploy-role/main.tf create mode 100644 terraform/live/modules/deploy-role/outputs.tf create mode 100644 terraform/live/modules/deploy-role/variables.tf create mode 100644 terraform/live/modules/environment-inventory/main.tf create mode 100644 terraform/live/modules/environment-inventory/outputs.tf create mode 100644 terraform/live/modules/environment-inventory/variables.tf create mode 100644 terraform/live/staging/.terraform.lock.hcl create mode 100644 terraform/live/staging/imports.tf create mode 100644 terraform/live/staging/main.tf create mode 100644 terraform/live/staging/outputs.tf create mode 100644 terraform/live/staging/providers.tf create mode 100644 terraform/live/staging/versions.tf create mode 100644 terraform/locals.tf create mode 100644 terraform/outputs.tf create mode 100644 terraform/providers.tf create mode 100644 terraform/rds.tf create mode 100644 terraform/secrets.tf create mode 100644 terraform/security_groups.tf create mode 100644 terraform/terraform.tfvars.example create mode 100644 terraform/variables.tf create mode 100644 terraform/versions.tf diff --git a/.github/workflows/ci-terraform.yaml b/.github/workflows/ci-terraform.yaml new file mode 100644 index 0000000..d62a219 --- /dev/null +++ b/.github/workflows/ci-terraform.yaml @@ -0,0 +1,50 @@ +name: Terraform CI + +on: + pull_request: + branches: [dev, staging, main] + paths: + - "terraform/**" + - "scripts/check-terraform-import-plan.py" + - "scripts/test-terraform-import-plan-check.py" + - ".github/workflows/ci-terraform.yaml" + push: + branches: [dev, staging, main] + paths: + - "terraform/**" + - "scripts/check-terraform-import-plan.py" + - "scripts/test-terraform-import-plan-check.py" + - ".github/workflows/ci-terraform.yaml" + +permissions: + contents: read + +jobs: + terraform: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + dir: + - terraform + - terraform/bootstrap + - terraform/live/dev + - terraform/live/staging + defaults: + run: + working-directory: ${{ matrix.dir }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - uses: hashicorp/setup-terraform@dfe3c3f87815947d99a8997f908cb6525fc44e9e # v4.0.1 + with: + terraform_version: "1.9.8" + + - name: Terraform fmt + run: terraform fmt -check -recursive + + - name: Terraform init + run: terraform init -backend=false + + - name: Terraform validate + run: terraform validate diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 5981e3a..1653756 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -1,10 +1,10 @@ -name: Validate and deploy dev +name: Validate and deploy on: pull_request: - branches: [dev] + branches: [dev, staging, main] push: - branches: [dev] + branches: [dev, staging, main] workflow_dispatch: permissions: @@ -32,6 +32,11 @@ jobs: cache: npm cache-dependency-path: infra/cdk/package-lock.json + - name: Set up Terraform + uses: hashicorp/setup-terraform@dfe3c3f87815947d99a8997f908cb6525fc44e9e # v4.0.1 + with: + terraform_version: "1.9.8" + - name: Repository quality gate run: bash scripts/governance-check.sh @@ -40,6 +45,15 @@ jobs: npm ci --prefix infra/cdk npm run synth --prefix infra/cdk + - name: Terraform fmt and validate + run: | + set -euo pipefail + for dir in terraform terraform/bootstrap; do + terraform -chdir="$dir" fmt -check -recursive + terraform -chdir="$dir" init -backend=false + terraform -chdir="$dir" validate + done + - name: Build Elastic Beanstalk source bundle run: bash scripts/package-elastic-beanstalk.sh @@ -66,22 +80,61 @@ jobs: .artifacts/elastic-beanstalk/webhook-config.txt deploy: - name: Deploy shoc-backend to Elastic Beanstalk dev - if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/dev') + name: Deploy shoc-backend to Elastic Beanstalk + if: > + github.event_name == 'push' || + (github.event_name == 'workflow_dispatch' && + contains(fromJSON('["refs/heads/dev","refs/heads/staging","refs/heads/main"]'), github.ref)) needs: validate runs-on: ubuntu-latest permissions: contents: read id-token: write environment: - name: dev + name: ${{ github.ref_name == 'main' && 'prod' || github.ref_name }} concurrency: - group: deploy-dev + group: deploy-${{ github.ref_name == 'main' && 'prod' || github.ref_name }} cancel-in-progress: false steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Resolve deploy target + id: target + run: | + set -euo pipefail + case "${GITHUB_REF_NAME}" in + dev) + application=shoc-backend + environment=shoc-backend-dev + smoke_url=https://api.dev.seahaven.com + ;; + staging) + application=shoc-backend + environment=shoc-backend-staging + smoke_url=https://api.staging.seahaven.com + ;; + main) + application=shoc-backend + environment=shoc-backend-prod + smoke_url=https://api.seahaven.com + ;; + *) + echo "Unsupported ref ${GITHUB_REF_NAME}" >&2 + exit 1 + ;; + esac + { + echo "application=${application}" + echo "environment=${environment}" + echo "smoke_url=${smoke_url}" + } >> "${GITHUB_OUTPUT}" + { + echo "EB_APPLICATION_NAME=${application}" + echo "EB_ENVIRONMENT_NAME=${environment}" + echo "SMOKE_URL=${smoke_url}" + } >> "${GITHUB_ENV}" + - name: Set up .NET uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 with: @@ -101,7 +154,7 @@ jobs: run: | set -euo pipefail prev="$(aws elasticbeanstalk describe-environments \ - --environment-names shoc-backend-dev \ + --environment-names "${EB_ENVIRONMENT_NAME}" \ --region us-east-1 \ --query 'Environments[0].VersionLabel' \ --output text)" @@ -112,8 +165,8 @@ jobs: uses: aws-actions/aws-elasticbeanstalk-deploy@7883cdd454c162051bf6fc13389536b045149b4c # v1.0.8 with: aws-region: us-east-1 - application-name: shoc-backend - environment-name: shoc-backend-dev + application-name: ${{ steps.target.outputs.application }} + environment-name: ${{ steps.target.outputs.environment }} version-label: ${{ github.sha }}-${{ github.run_id }}-${{ github.run_attempt }} deployment-package-path: .artifacts/elastic-beanstalk/site.zip s3-bucket-name: elasticbeanstalk-us-east-1-396287094661 @@ -135,7 +188,7 @@ jobs: for _ in $(seq 1 80); do read -r status current health < <( aws elasticbeanstalk describe-environments \ - --environment-names shoc-backend-dev \ + --environment-names "${EB_ENVIRONMENT_NAME}" \ --region us-east-1 \ --query 'Environments[0].[Status,VersionLabel,Health]' \ --output text @@ -157,7 +210,7 @@ jobs: exit 1 - name: Post-deploy smoke - run: bash scripts/smoke-elastic-beanstalk.sh https://api.dev.seahaven.com + run: bash scripts/smoke-elastic-beanstalk.sh "${SMOKE_URL}" - name: Verify webhook secret source is operational run: | @@ -173,7 +226,7 @@ jobs: --header 'X-SH-Key-Id: deployment-smoke-invalid-key' \ --header "X-SH-Signature: v1=$(printf '0%.0s' {1..64})" \ --data '{}' \ - https://api.dev.seahaven.com/api/webhooks/work-orders)" + "${SMOKE_URL}/api/webhooks/work-orders")" if [ "$status" != "401" ]; then echo "Expected enabled webhook with an operational secret source to reject the invalid probe with 401; received $status." >&2 sed -n '1,20p' "$response_file" >&2 @@ -202,7 +255,7 @@ jobs: for _ in $(seq 1 80); do read -r status current health < <( aws elasticbeanstalk describe-environments \ - --environment-names shoc-backend-dev \ + --environment-names "${EB_ENVIRONMENT_NAME}" \ --region us-east-1 \ --query 'Environments[0].[Status,VersionLabel,Health]' \ --output text @@ -223,10 +276,10 @@ jobs: exit 0 fi - echo "Restoring shoc-backend-dev application code to version label: $prev" + echo "Restoring ${EB_ENVIRONMENT_NAME} application code to version label: $prev" echo "Database migrations are not reverted; deployable migrations must follow the expand/contract policy." aws elasticbeanstalk update-environment \ - --environment-name shoc-backend-dev \ + --environment-name "${EB_ENVIRONMENT_NAME}" \ --version-label "$prev" \ --region us-east-1 @@ -234,7 +287,7 @@ jobs: for _ in $(seq 1 80); do read -r status current health < <( aws elasticbeanstalk describe-environments \ - --environment-names shoc-backend-dev \ + --environment-names "${EB_ENVIRONMENT_NAME}" \ --region us-east-1 \ --query 'Environments[0].[Status,VersionLabel,Health]' \ --output text diff --git a/.gitignore b/.gitignore index bac4700..659a71a 100644 --- a/.gitignore +++ b/.gitignore @@ -374,3 +374,14 @@ infra/cdk/.cdk.staging/ # Deployment packaging artifacts .artifacts/ + +# Terraform (HCP remote state; never commit tfvars with secrets) +**/.terraform/ +*.tfvars +!*.tfvars.example +crash.log +crash.*.log +override.tf +override.tf.json +*_override.tf +*_override.tf.json diff --git a/QUALITY_GATES.md b/QUALITY_GATES.md index e7b5c84..fac67ac 100644 --- a/QUALITY_GATES.md +++ b/QUALITY_GATES.md @@ -24,6 +24,7 @@ | G7 | Cancellation forwarding | §6 | behavior tests on changed I/O paths + analyzer | review-enforced on changed paths | | G8 | Error disclosure | §5 | `SanitizedErrorsTests` (part of G5) | `ci` | | G9 | Board-backed regression | review framework | `REVIEW_AND_PR_FRAMEWORK.md` inventory | review-enforced | +| G10 | Terraform import plan safety | live infrastructure adoption | `python scripts/test-terraform-import-plan-check.py` | `architecture-quality` → `governance-check.sh` | ## How to run locally @@ -45,6 +46,8 @@ The script: changed C# files it skips G3 with an explicit "skipped: no changed C#" line. 4. builds the complete solution in Release with no restore (G4). 5. runs the complete solution test suite in Release with no rebuild (G5). +6. verifies that the Terraform plan guard rejects create, delete, replacement, + and unapproved update actions (G10). ## Migration gates (G6) diff --git a/scripts/check-terraform-import-plan.py b/scripts/check-terraform-import-plan.py new file mode 100644 index 0000000..4d6ac9e --- /dev/null +++ b/scripts/check-terraform-import-plan.py @@ -0,0 +1,74 @@ +#!/usr/bin/env python3 +"""Reject unsafe actions in a live Terraform import plan.""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path + + +ALLOWED_MANAGED_TYPES = {"aws_iam_role", "aws_iam_role_policy"} +UNSAFE_ACTIONS = {"create", "delete"} + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser() + parser.add_argument("plan_json", type=Path) + parser.add_argument( + "--allow-update", + action="store_true", + help="Allow in-place updates after the initial no-op import is proven.", + ) + return parser.parse_args() + + +def main() -> int: + args = parse_args() + plan = json.loads(args.plan_json.read_text(encoding="utf-8")) + violations: list[str] = [] + managed = 0 + updates = 0 + + for resource in plan.get("resource_changes", []): + if resource.get("mode", "managed") != "managed": + continue + + resource_type = resource.get("type", "") + address = resource.get("address", "") + actions = set(resource.get("change", {}).get("actions", [])) + managed += 1 + + if resource_type not in ALLOWED_MANAGED_TYPES: + violations.append( + f"{address}: managed type {resource_type!r} is outside the live ownership boundary" + ) + + unsafe = sorted(actions & UNSAFE_ACTIONS) + if unsafe: + violations.append(f"{address}: unsafe actions {unsafe}") + + if "update" in actions: + updates += 1 + if not args.allow_update: + violations.append( + f"{address}: update is forbidden during the initial no-op import" + ) + + if violations: + print("FAIL: live Terraform plan is not import-safe", file=sys.stderr) + for violation in violations: + print(f" - {violation}", file=sys.stderr) + return 1 + + mode = "controlled update" if args.allow_update else "no-op import" + print( + f"PASS: {mode} plan has {managed} managed resources, " + f"{updates} updates, and no create/delete/replace actions" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/deploy-api-tf.sh b/scripts/deploy-api-tf.sh new file mode 100644 index 0000000..a376fc9 --- /dev/null +++ b/scripts/deploy-api-tf.sh @@ -0,0 +1,113 @@ +#!/usr/bin/env bash +# +# deploy-api-tf.sh — local Elastic Beanstalk publish for the Terraform POC. +# Refuses live names. GitHub Actions is the real CD path once the branch is +# pushed; this script exists only because GHA cannot run until then. +# +# Usage: +# export AWS_PROFILE=seahaven-external-dev +# bash scripts/deploy-api-tf.sh +set -euo pipefail + +REGION="${AWS_REGION:-us-east-1}" +APPLICATION_NAME="shoc-backend-tf-poc" +ENVIRONMENT_NAME="shoc-backend-tf-poc" +SMOKE_URL="https://tf-poc.api.dev.seahaven.com" +S3_BUCKET="elasticbeanstalk-us-east-1-396287094661" + +log() { printf '\n\033[1m== %s ==\033[0m\n' "$1"; } +die() { printf '\033[31mERR\033[0m %s\n' "$1" >&2; exit 1; } + +[[ "${APPLICATION_NAME}" != "shoc-backend" ]] \ + || die "refusing live Elastic Beanstalk application shoc-backend" +[[ "${ENVIRONMENT_NAME}" != "shoc-backend-dev" ]] \ + || die "refusing live Elastic Beanstalk environment shoc-backend-dev" +[[ "${SMOKE_URL}" != "https://api.dev.seahaven.com" ]] \ + || die "refusing live hostname api.dev.seahaven.com" + +command -v aws >/dev/null 2>&1 || die "aws CLI is required" +command -v curl >/dev/null 2>&1 || die "curl is required" + +ACCOUNT="$(aws sts get-caller-identity --query Account --output text)" +[[ "${ACCOUNT}" == "396287094661" ]] \ + || die "refusing to deploy outside seahaven-external-dev (caller account ${ACCOUNT})" + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$REPO_ROOT" + +log "package source bundle" +bash scripts/package-elastic-beanstalk.sh + +VERSION_LABEL="local-$(date -u +%Y%m%d%H%M%S)-${USER:-unknown}" +BUNDLE=".artifacts/elastic-beanstalk/site.zip" +KEY="shoc-backend-tf-poc/${VERSION_LABEL}.zip" + +log "capture current environment version" +prev="$(aws elasticbeanstalk describe-environments \ + --environment-names "${ENVIRONMENT_NAME}" \ + --region "${REGION}" \ + --query 'Environments[0].VersionLabel' \ + --output text)" +echo "${prev}" > .artifacts/elastic-beanstalk/previous-version.txt +echo "Previous version label: ${prev}" + +log "upload bundle ${KEY}" +aws s3 cp "${BUNDLE}" "s3://${S3_BUCKET}/${KEY}" --region "${REGION}" + +log "create application version ${VERSION_LABEL}" +aws elasticbeanstalk create-application-version \ + --application-name "${APPLICATION_NAME}" \ + --version-label "${VERSION_LABEL}" \ + --source-bundle "S3Bucket=${S3_BUCKET},S3Key=${KEY}" \ + --region "${REGION}" + +log "update environment ${ENVIRONMENT_NAME}" +aws elasticbeanstalk update-environment \ + --environment-name "${ENVIRONMENT_NAME}" \ + --version-label "${VERSION_LABEL}" \ + --region "${REGION}" + +log "wait until expected version is Ready" +status="Unknown" +current="Unknown" +health="Unknown" +for _ in $(seq 1 80); do + read -r status current health < <( + aws elasticbeanstalk describe-environments \ + --environment-names "${ENVIRONMENT_NAME}" \ + --region "${REGION}" \ + --query 'Environments[0].[Status,VersionLabel,Health]' \ + --output text + ) + echo "environment status: ${status}; version: ${current}; health: ${health}" + if [ "${status}" = "Ready" ]; then + if [ "${current}" = "${VERSION_LABEL}" ] && { [ "${health}" = "Green" ] || [ "${health}" = "Yellow" ]; }; then + echo "Expected application version is Ready and healthy." + break + fi + die "Environment became Ready without activating expected version ${VERSION_LABEL}." + fi + sleep 15 +done +[[ "${status}" = "Ready" ]] || die "Expected application version did not become Ready." + +log "smoke ${SMOKE_URL}" +bash scripts/smoke-elastic-beanstalk.sh "${SMOKE_URL}" + +log "webhook secret source" +response_file="$(mktemp)" +trap 'rm -f "$response_file"' EXIT +status_code="$(curl --silent --show-error \ + --output "$response_file" \ + --write-out '%{http_code}' \ + --request POST \ + --header 'Content-Type: application/json' \ + --header "X-SH-Timestamp: $(date +%s)" \ + --header 'X-SH-Key-Id: deployment-smoke-invalid-key' \ + --header "X-SH-Signature: v1=$(printf '0%.0s' {1..64})" \ + --data '{}' \ + "${SMOKE_URL}/api/webhooks/work-orders")" +if [ "${status_code}" != "401" ]; then + die "Expected enabled webhook to reject the invalid probe with 401; received ${status_code}." +fi +echo "webhook HTTP 401" diff --git a/scripts/governance-check.sh b/scripts/governance-check.sh index 4175287..503ef32 100755 --- a/scripts/governance-check.sh +++ b/scripts/governance-check.sh @@ -79,4 +79,8 @@ log "G5: full test suite" "$DOTNET" test "$SOLUTION" -c Release --no-build --nologo ok "G5: full test suite" +log "G10: Terraform import plan safety" +python scripts/test-terraform-import-plan-check.py +ok "G10: Terraform import plan safety" + log "governance-check: all required repository gates passed" diff --git a/scripts/test-terraform-import-plan-check.py b/scripts/test-terraform-import-plan-check.py new file mode 100644 index 0000000..d35718e --- /dev/null +++ b/scripts/test-terraform-import-plan-check.py @@ -0,0 +1,69 @@ +#!/usr/bin/env python3 +"""Small deterministic tests for check-terraform-import-plan.py.""" + +from __future__ import annotations + +import json +import subprocess +import sys +import tempfile +from pathlib import Path + + +SCRIPT = Path(__file__).with_name("check-terraform-import-plan.py") + + +def run_case(actions: list[str], *, allow_update: bool = False) -> subprocess.CompletedProcess[str]: + plan = { + "resource_changes": [ + { + "address": "module.deploy_role.aws_iam_role.github_deploy", + "mode": "managed", + "type": "aws_iam_role", + "change": {"actions": actions}, + } + ] + } + + with tempfile.TemporaryDirectory() as directory: + plan_path = Path(directory) / "plan.json" + plan_path.write_text(json.dumps(plan), encoding="utf-8") + command = [sys.executable, str(SCRIPT), str(plan_path)] + if allow_update: + command.append("--allow-update") + return subprocess.run(command, check=False, capture_output=True, text=True) + + +def main() -> int: + cases = [ + ("no-op import", run_case(["no-op"]), 0), + ("initial update", run_case(["update"]), 1), + ("controlled update", run_case(["update"], allow_update=True), 0), + ("create", run_case(["create"]), 1), + ("replacement", run_case(["delete", "create"]), 1), + ("destroy", run_case(["delete"]), 1), + ] + failures = [ + (name, result, expected) + for name, result, expected in cases + if result.returncode != expected + ] + if failures: + print( + "FAIL: plan-check cases failed: " + + ", ".join(name for name, _, _ in failures), + file=sys.stderr, + ) + for name, result, expected in failures: + print( + f"{name}: expected {expected}, got {result.returncode}\n" + f"{result.stdout}{result.stderr}", + file=sys.stderr, + ) + return 1 + print("PASS: Terraform import plan safety checks") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/terraform/.terraform.lock.hcl b/terraform/.terraform.lock.hcl new file mode 100644 index 0000000..7b118f9 --- /dev/null +++ b/terraform/.terraform.lock.hcl @@ -0,0 +1,53 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/aws" { + version = "6.62.0" + constraints = "~> 6.57" + hashes = [ + "h1:4qcuRkosNKYxV2y69uJ6zAfTEO1Op04L4KUuWBrUvBo=", + "h1:OthB9UeoBgmy348EpDjs5GDGk6p6UxAMQD5cXn7u9Ho=", + "h1:lTKd2c1EunGxt2XROLgEeSXA2Jk+WiiG9BTcp+L/0xY=", + "h1:nWSI/kgPk9aieiY01TEKOGXRX3+L889GSkEq0SMCL6E=", + "zh:35a9e4bc6fd622c5a99561b882025f2745f1256bbf1a8da8d6b39319b75ae0b5", + "zh:405927d470ff16201e40aa0fa2d0ab1de477360a0926d20719cd029179682ecd", + "zh:4ab7866593a90bcf18f066b0092a209b9f42852acd783b504031ae74cb6f7010", + "zh:5b477f313fc511648a4eed9f9085d0778414835896256ab14296d2345b7070e3", + "zh:87de70bc99751f94262cec2260d972555a98f588aa3a613e417438f88a1182df", + "zh:88f02a8ff07f00da4ffb3bee9e8ae25588e3a0a92633c625c1c2a63bac00a844", + "zh:8d8596257453357c9f3fccaa7d2f04299e8d35b16f364adb8a2c829143a9c090", + "zh:953c8e15fa9c12c081f17d66cf45246d032fa23bee33f264dc82242afdb98bc2", + "zh:9a7dd903e5e9b2b0cc1317ad2d2692e0ddf05ae2a5aaee20ec5dd1db456711b7", + "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425", + "zh:a859154c75c1088d098a481f1ebb259720c5a2ad87781364abf556a741e5adb7", + "zh:b8d1e72ad39d5864118f64dd3273424ab637d34b3ff8dd3dfeb4aef9d458587f", + "zh:c3666fcfc7b131f5282d4e7249fa68c3a21665757888aa02bbaf6be1cd036bba", + "zh:c6b8ff94b3f49bf85fc087381cfe1b271c5b01cf74cf140d58aa500be7138913", + "zh:d8143d790e9dd77b8e2f9168e4a33ad6d064dc4b082a0196636b182105aaed14", + "zh:fa41eca042f377eb2741e95b36609c1de5b0cd675cd4e3e30c709497cb94db02", + ] +} + +provider "registry.terraform.io/hashicorp/random" { + version = "3.9.0" + constraints = "~> 3.6" + hashes = [ + "h1:OO+IuvQJSPmWdN8AyyIEvPJbLvDQpgX/zbktoa9KsJE=", + "h1:UlBuNVuCGJ39tTv2c5gz2NRZnQbXfbIWbTzWcth5o74=", + "h1:o0s5Mk9NXMP60nlheO1r0LsDGGratFb3oL0t7bD2QnM=", + "h1:q/uaUTBdKgAmZESrwsoeDQff9uUA/cI/N5ZKNgVwa9c=", + "zh:161ad0bd9a75768c82f53fb6e7172a9d8be2d4889b012645a34795031aaf1bf1", + "zh:19dc9a5b17729725ccfc4f45b0500af0ee5bc6b6b160c7adb8f2bf617d2c80ea", + "zh:269eda8fe42daa7974d5a34d166c3ba9defe80cde86c01e4dadcfdf2e1f05e5f", + "zh:373f7c65566f8f2cc7f45d698654feb9d988996957e1266a69ca00c52d6d16d0", + "zh:5599d16804c41c83009ec621b6d6b6f74e102f5827678a4750f8809055546b61", + "zh:583be0440469a22bff70dcfa56593b01566860b29607437264adb51060cf46fc", + "zh:5f211d8ec3f2e1f414870d9584bfe26e6995560ef81c748f8447a48164767398", + "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", + "zh:7b547fd16216761ef86efc3ed516ac5ac0c5c42b7c7eb24a08cef2d93f69ed5e", + "zh:7e7c0679daf2a382151d05068c8c3f0dae6b7b7dccf818827b73dd08638df2ef", + "zh:8089dec888a8038b9b4fb23b3df7e1057293dbc5b60b42cc47ff690d69d4b61b", + "zh:c51f15a031edfd6f23ce8ced3446ca7f8d8d647e2499890d7d5d10d5016d7257", + "zh:c94784f005708890dc6895afd53636ec00ec1e430b15d41e5aebfb1d4b39bd04", + ] +} diff --git a/terraform/README.md b/terraform/README.md new file mode 100644 index 0000000..76ef6ec --- /dev/null +++ b/terraform/README.md @@ -0,0 +1,170 @@ +# Terraform deployment infrastructure + +The root module remains the isolated `tf-poc` Elastic Beanstalk and SQL Server +stack in `seahaven-external-dev` (`396287094661`). It must never be reused for +dev or staging state. + +Live adoption is deliberately smaller. [`live/`](live/) imports only the +existing GitHub Actions deploy roles and inline policies. All application, +environment, database, certificate, DNS, network, runtime IAM, and secret +resources remain external and are read only as inventory. + +The Terraform roots are: + +- `./`: isolated POC workload in `shoc-backend-tf-poc`. +- `bootstrap/`: consolidated POC/dev/staging HCP role bootstrap in + `shoc-backend-bootstrap`. +- `live/dev/`: import-only dev deploy-role ownership in + `shoc-backend-dev`. +- `live/staging/`: import-only staging deploy-role ownership in + `shoc-backend-staging`. + +IAM lives in this repo, not org-baseline. App CD never runs a bootstrap root. +Auto-apply stays off for every workspace. + +The stabilized POC bootstrap deliberately leaves both POC HCP roles read only +and removes `ViewOnlyAccess` plus the broad workload-mutation inline policy. +The POC GitHub deploy role remains unchanged until a separately approved +narrowing or teardown. Future POC teardown runs directly under the approved +SSO administrator session, not the locked HCP apply role. + +## Locked names + +- Hostname: `tf-poc.api.dev.seahaven.com` (zone `Z07671212N75U4YLPWZR8`) +- EB application / environment: `shoc-backend-tf-poc` +- RDS identifier: `shoc-backend-tf-poc` +- Catalog: `shoc_tf_poc` (create this database once after RDS is available) +- Deploy role: `arn:aws:iam::396287094661:role/tf-managed/githubdeploy-shoc-backend-tf-poc` +- GitHub Environment: `tf-poc` (OIDC `environment:tf-poc`) +- HCP org `seahaven`, project `seahaven-external-dev` + +## HCP layout + +All SHOC backend environments live in AWS account `396287094661` and HCP + +The POC workspaces remain isolated until teardown. The live bootstrap owns +`hcptf-shoc-backend-{dev,staging}` and matching `-plan` roles. The live +environment workspaces own only their existing GitHub deploy role and inline +policy. See [`live/README.md`](live/README.md). + +## Console setup (once) + +1. In HCP Terraform, create project `seahaven-external-dev` if it does not exist. +2. Create workspace `shoc-backend-bootstrap`: + - VCS later, or CLI-driven until the branch is pushed + - Working directory: `terraform/bootstrap` + - Execution mode: **Local** + - Auto-apply: off +3. Create workspace `shoc-backend-tf-poc`: + - Same branch + - Working directory: `terraform` + - Execution mode: **Remote** + - Auto-apply: off + - Speculative plans: on +4. Do **not** use HCP "Quick setup AWS dynamic credentials". + +## Discovery (before first workload apply) + +```bash +export AWS_PROFILE=seahaven-external-dev + +aws elasticbeanstalk describe-environments \ + --environment-names shoc-backend-dev \ + --region us-east-1 \ + --query 'Environments[0].{Vpc:EndpointURL}' + +aws elasticbeanstalk describe-configuration-settings \ + --application-name shoc-backend \ + --environment-name shoc-backend-dev \ + --region us-east-1 \ + --query "ConfigurationSettings[0].OptionSettings[?Namespace=='aws:ec2:vpc']" +``` + +Copy `vpc-REPLACE_ME` and subnet lists into a local `terraform/terraform.tfvars` +(gitignored). Confirm: + +- `app.terraform.io` OIDC exists (`create_tfc_oidc_provider=false` in bootstrap). + If the data source fails, set `create_tfc_oidc_provider=true`. +- `external-dev-execution-boundary` exists. +- `aws-elasticbeanstalk-service-role` exists. +- GitHub OIDC provider `token.actions.githubusercontent.com` exists. + +## Bootstrap apply (Adam) + +SSO AdministratorAccess in this account is subject to the external-dev SCP, so +both `hcptf-*` roles set `permissions_boundary` to +`external-dev-execution-boundary`. + +```bash +export AWS_PROFILE=seahaven-external-dev +cd terraform/bootstrap +terraform login +terraform init +terraform apply +``` + +Then on workspace `shoc-backend-tf-poc`, set workspace-scoped env vars: + +- `TFC_AWS_PROVIDER_AUTH=true` +- `TFC_AWS_PLAN_ROLE_ARN` = output `hcp_plan_role_arn` +- `TFC_AWS_APPLY_ROLE_ARN` = output `hcp_apply_role_arn` + +Never put those in a project variable set. Set `sendgrid_api_key` as a +sensitive Terraform variable on that workspace when you want mail to work. + +## Workload apply + +HCP Manual apply on `shoc-backend-tf-poc`. Confirm `api.dev.seahaven.com` still +serves live before and after. + +After RDS is available, create the catalog once (SQL Server Express does not +accept `db_name` on `aws_db_instance`): + +```sql +CREATE DATABASE [shoc_tf_poc]; +``` + +Then first app deploy can run migrations into that catalog. + +## App deploy + +GitHub Actions is the real CD path. `.github/workflows/deploy.yml` maps: + +- `dev` → `dev` +- `staging` → `staging` +- `main` → `prod` + +The live roots import the roles already referenced by the `dev` and `staging` +GitHub Environment secrets. The role ARNs do not change during adoption. + +The POC local fallback remains available until teardown: + +```bash +export AWS_PROFILE=seahaven-external-dev +bash scripts/deploy-api-tf.sh +``` + +The script refuses live names (`shoc-backend-dev`, `api.dev.seahaven.com`). + +## After POC confirmation + +1. Create `shoc-backend-dev` and `shoc-backend-staging` workspaces. Do not + reuse POC state. +2. Apply `bootstrap/` only after approval to create the four narrowly scoped + live HCP roles. +3. Follow the no-op import and controlled-update sequence in + [`live/README.md`](live/README.md). +4. Retire CDK deploy-role ownership only after both role imports are proven. +5. Destroy the POC workload last. Bootstrap state remains. + +Do not apply this module as `environment=dev` without imports. + +## Local CI equivalent + +```bash +terraform -chdir=terraform fmt -check -recursive +terraform -chdir=terraform init -backend=false && terraform -chdir=terraform validate +terraform -chdir=terraform/bootstrap init -backend=false && terraform -chdir=terraform/bootstrap validate +terraform -chdir=terraform/live/dev init -backend=false && terraform -chdir=terraform/live/dev validate +terraform -chdir=terraform/live/staging init -backend=false && terraform -chdir=terraform/live/staging validate +``` diff --git a/terraform/acm.tf b/terraform/acm.tf new file mode 100644 index 0000000..4e07b60 --- /dev/null +++ b/terraform/acm.tf @@ -0,0 +1,37 @@ +resource "aws_acm_certificate" "api" { + domain_name = var.domain_name + validation_method = "DNS" + + tags = { + Name = var.domain_name + Project = "shoc-backend" + } + + lifecycle { + create_before_destroy = true + } + + depends_on = [terraform_data.account_guard] +} + +resource "aws_route53_record" "acm_validation" { + for_each = { + for dvo in aws_acm_certificate.api.domain_validation_options : dvo.domain_name => { + name = dvo.resource_record_name + record = dvo.resource_record_value + type = dvo.resource_record_type + } + } + + allow_overwrite = true + name = each.value.name + records = [each.value.record] + ttl = 60 + type = each.value.type + zone_id = var.hosted_zone_id +} + +resource "aws_acm_certificate_validation" "api" { + certificate_arn = aws_acm_certificate.api.arn + validation_record_fqdns = [for record in aws_route53_record.acm_validation : record.fqdn] +} diff --git a/terraform/bootstrap/.terraform.lock.hcl b/terraform/bootstrap/.terraform.lock.hcl new file mode 100644 index 0000000..52af5b2 --- /dev/null +++ b/terraform/bootstrap/.terraform.lock.hcl @@ -0,0 +1,29 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/aws" { + version = "6.62.0" + constraints = "~> 6.57" + hashes = [ + "h1:4qcuRkosNKYxV2y69uJ6zAfTEO1Op04L4KUuWBrUvBo=", + "h1:OthB9UeoBgmy348EpDjs5GDGk6p6UxAMQD5cXn7u9Ho=", + "h1:lTKd2c1EunGxt2XROLgEeSXA2Jk+WiiG9BTcp+L/0xY=", + "h1:nWSI/kgPk9aieiY01TEKOGXRX3+L889GSkEq0SMCL6E=", + "zh:35a9e4bc6fd622c5a99561b882025f2745f1256bbf1a8da8d6b39319b75ae0b5", + "zh:405927d470ff16201e40aa0fa2d0ab1de477360a0926d20719cd029179682ecd", + "zh:4ab7866593a90bcf18f066b0092a209b9f42852acd783b504031ae74cb6f7010", + "zh:5b477f313fc511648a4eed9f9085d0778414835896256ab14296d2345b7070e3", + "zh:87de70bc99751f94262cec2260d972555a98f588aa3a613e417438f88a1182df", + "zh:88f02a8ff07f00da4ffb3bee9e8ae25588e3a0a92633c625c1c2a63bac00a844", + "zh:8d8596257453357c9f3fccaa7d2f04299e8d35b16f364adb8a2c829143a9c090", + "zh:953c8e15fa9c12c081f17d66cf45246d032fa23bee33f264dc82242afdb98bc2", + "zh:9a7dd903e5e9b2b0cc1317ad2d2692e0ddf05ae2a5aaee20ec5dd1db456711b7", + "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425", + "zh:a859154c75c1088d098a481f1ebb259720c5a2ad87781364abf556a741e5adb7", + "zh:b8d1e72ad39d5864118f64dd3273424ab637d34b3ff8dd3dfeb4aef9d458587f", + "zh:c3666fcfc7b131f5282d4e7249fa68c3a21665757888aa02bbaf6be1cd036bba", + "zh:c6b8ff94b3f49bf85fc087381cfe1b271c5b01cf74cf140d58aa500be7138913", + "zh:d8143d790e9dd77b8e2f9168e4a33ad6d064dc4b082a0196636b182105aaed14", + "zh:fa41eca042f377eb2741e95b36609c1de5b0cd675cd4e3e30c709497cb94db02", + ] +} diff --git a/terraform/bootstrap/iam.tf b/terraform/bootstrap/iam.tf new file mode 100644 index 0000000..4ab02b6 --- /dev/null +++ b/terraform/bootstrap/iam.tf @@ -0,0 +1,228 @@ +# Account-global HCP Terraform OIDC provider. An account may hold exactly one +# provider per URL. Default is data-source because the frontend stack owns it. +resource "aws_iam_openid_connect_provider" "terraform_cloud" { + count = var.create_tfc_oidc_provider ? 1 : 0 + + url = "https://app.terraform.io" + client_id_list = ["aws.workload.identity"] + thumbprint_list = ["9e99a48a9960b14926bb7f3b02e22da2b0ab7280"] + + lifecycle { + prevent_destroy = true + } +} + +data "aws_iam_policy_document" "hcp_plan_assume" { + statement { + effect = "Allow" + actions = ["sts:AssumeRoleWithWebIdentity"] + + principals { + type = "Federated" + identifiers = [local.tfc_oidc_arn] + } + + condition { + test = "StringEquals" + variable = "app.terraform.io:aud" + values = ["aws.workload.identity"] + } + + condition { + test = "StringEquals" + variable = "app.terraform.io:sub" + values = ["${local.hcp_sub_prefix}:plan"] + } + } +} + +data "aws_iam_policy_document" "hcp_apply_assume" { + statement { + effect = "Allow" + actions = ["sts:AssumeRoleWithWebIdentity"] + + principals { + type = "Federated" + identifiers = [local.tfc_oidc_arn] + } + + condition { + test = "StringEquals" + variable = "app.terraform.io:aud" + values = ["aws.workload.identity"] + } + + condition { + test = "StringEquals" + variable = "app.terraform.io:sub" + values = ["${local.hcp_sub_prefix}:apply"] + } + } +} + +data "aws_iam_policy_document" "hcp_refresh" { + statement { + sid = "RefreshManagedIam" + effect = "Allow" + actions = [ + "iam:GetInstanceProfile", + "iam:GetRole", + "iam:GetRolePolicy", + "iam:ListAttachedRolePolicies", + "iam:ListInstanceProfilesForRole", + "iam:ListRolePolicies", + "iam:ListRoleTags", + ] + resources = [ + local.github_deploy_role_arn, + local.eb_ec2_role_arn, + local.eb_service_role_arn, + "arn:aws:iam::${local.account_id}:instance-profile/tf-managed/${var.eb_ec2_role_name}", + ] + } + + statement { + sid = "ListOidcProviders" + effect = "Allow" + actions = ["iam:ListOpenIDConnectProviders"] + resources = ["*"] + } + + statement { + sid = "ReadGithubOidcProvider" + effect = "Allow" + actions = ["iam:GetOpenIDConnectProvider"] + resources = [local.github_oidc_arn] + } + + statement { + sid = "RefreshRds" + effect = "Allow" + actions = [ + "rds:DescribeDBInstances", + "rds:DescribeDBParameterGroups", + "rds:DescribeDBSubnetGroups", + "rds:ListTagsForResource", + ] + # RDS describe APIs do not support resource-level permissions. + resources = ["*"] + } + + statement { + sid = "RefreshSecrets" + effect = "Allow" + actions = [ + "secretsmanager:DescribeSecret", + "secretsmanager:GetResourcePolicy", + "secretsmanager:GetSecretValue", + "secretsmanager:ListSecretVersionIds", + ] + resources = [ + "arn:aws:secretsmanager:${var.aws_region}:${local.account_id}:secret:shoc-backend-tf-poc/jwt-JXLaUx", + "arn:aws:secretsmanager:${var.aws_region}:${local.account_id}:secret:shoc-backend-tf-poc/webhook-hmac-eThZhu", + "arn:aws:secretsmanager:${var.aws_region}:${local.account_id}:secret:rds!db-6e0e2e34-dea1-47b0-8e92-b90bde9cfe20-Mxeu3J", + ] + } + + statement { + sid = "ListCertificates" + effect = "Allow" + actions = ["acm:ListCertificates"] + resources = ["*"] + } + + statement { + sid = "RefreshElasticBeanstalk" + effect = "Allow" + actions = [ + "elasticbeanstalk:DescribeApplications", + "elasticbeanstalk:DescribeConfigurationOptions", + "elasticbeanstalk:DescribeConfigurationSettings", + "elasticbeanstalk:DescribeEnvironmentResources", + "elasticbeanstalk:DescribeEnvironments", + "elasticbeanstalk:ListTagsForResource", + ] + # Elastic Beanstalk describe APIs do not support resource-level permissions. + resources = ["*"] + } + + statement { + sid = "RefreshPocCertificate" + effect = "Allow" + actions = [ + "acm:DescribeCertificate", + "acm:GetCertificate", + "acm:ListTagsForCertificate", + ] + resources = ["arn:aws:acm:${var.aws_region}:${local.account_id}:certificate/4fcc2dff-bb11-4204-9107-86d6ce2b95a2"] + } + + statement { + sid = "RefreshRoute53" + effect = "Allow" + actions = [ + "route53:GetChange", + "route53:GetHostedZone", + "route53:ListResourceRecordSets", + "route53:ListTagsForResource", + ] + resources = [ + local.hosted_zone_arn, + "arn:aws:route53:::change/*", + ] + } + + statement { + sid = "RefreshNetwork" + effect = "Allow" + actions = [ + "ec2:DescribeSecurityGroupRules", + "ec2:DescribeSecurityGroups", + "ec2:DescribeSubnets", + "ec2:DescribeTags", + "ec2:DescribeVpcs", + ] + # EC2 describe APIs do not support resource-level permissions. + resources = ["*"] + } +} + +resource "aws_iam_role" "hcp_plan" { + name = "hcptf-shoc-backend-tf-poc-plan" + description = "HCP Terraform PLAN role for shoc-backend-tf-poc" + assume_role_policy = data.aws_iam_policy_document.hcp_plan_assume.json + max_session_duration = 3600 + permissions_boundary = var.execution_boundary_arn + + depends_on = [terraform_data.account_guard] + + lifecycle { + prevent_destroy = true + } +} + +resource "aws_iam_role_policy" "hcp_plan_refresh" { + name = "shoc-backend-tf-poc-plan-refresh" + role = aws_iam_role.hcp_plan.id + policy = data.aws_iam_policy_document.hcp_refresh.json +} + +resource "aws_iam_role" "hcp_apply" { + name = "hcptf-shoc-backend-tf-poc" + description = "Read-only HCP Terraform APPLY role for the stabilized shoc-backend-tf-poc" + assume_role_policy = data.aws_iam_policy_document.hcp_apply_assume.json + max_session_duration = 3600 + permissions_boundary = var.execution_boundary_arn + + depends_on = [terraform_data.account_guard] + + lifecycle { + prevent_destroy = true + } +} + +resource "aws_iam_role_policy" "hcp_apply_iam" { + name = "shoc-backend-tf-poc-iam" + role = aws_iam_role.hcp_apply.id + policy = data.aws_iam_policy_document.hcp_refresh.json +} diff --git a/terraform/bootstrap/live_workspace_roles.tf b/terraform/bootstrap/live_workspace_roles.tf new file mode 100644 index 0000000..556973f --- /dev/null +++ b/terraform/bootstrap/live_workspace_roles.tf @@ -0,0 +1,215 @@ +locals { + live_certificate_arn = "arn:aws:acm:${var.aws_region}:${local.account_id}:certificate/2b78e74f-7b65-4b82-a413-7a498b102f00" + live_github_oidc_arn = "arn:aws:iam::${local.account_id}:oidc-provider/token.actions.githubusercontent.com" + live_rds_arn = "arn:aws:rds:${var.aws_region}:${local.account_id}:db:shoc-sqlserver-shared" + + live_environments = { + dev = { + workspace = "shoc-backend-dev" + deploy_role_name = "githubdeploy-shoc-backend-dev" + runtime_role_name = "shoc-backend-dev" + instance_profile_name = "shoc-backend-dev" + hosted_zone_id = "Z07671212N75U4YLPWZR8" + } + staging = { + workspace = "shoc-backend-staging" + deploy_role_name = "githubdeploy-shoc-backend-staging" + runtime_role_name = "shoc-backend-staging" + instance_profile_name = "shoc-backend-staging" + hosted_zone_id = "Z02602739VQWBWCAGXP4" + } + } +} + +data "aws_iam_policy_document" "live_plan_assume" { + for_each = local.live_environments + + statement { + effect = "Allow" + actions = ["sts:AssumeRoleWithWebIdentity"] + + principals { + type = "Federated" + identifiers = [local.tfc_oidc_arn] + } + + condition { + test = "StringEquals" + variable = "app.terraform.io:aud" + values = ["aws.workload.identity"] + } + + condition { + test = "StringEquals" + variable = "app.terraform.io:sub" + values = ["organization:${var.hcp_organization}:project:${var.hcp_project}:workspace:${each.value.workspace}:run_phase:plan"] + } + } +} + +data "aws_iam_policy_document" "live_apply_assume" { + for_each = local.live_environments + + statement { + effect = "Allow" + actions = ["sts:AssumeRoleWithWebIdentity"] + + principals { + type = "Federated" + identifiers = [local.tfc_oidc_arn] + } + + condition { + test = "StringEquals" + variable = "app.terraform.io:aud" + values = ["aws.workload.identity"] + } + + condition { + test = "StringEquals" + variable = "app.terraform.io:sub" + values = ["organization:${var.hcp_organization}:project:${var.hcp_project}:workspace:${each.value.workspace}:run_phase:apply"] + } + } +} + +data "aws_iam_policy_document" "live_plan" { + for_each = local.live_environments + + statement { + sid = "CallerIdentity" + effect = "Allow" + actions = ["sts:GetCallerIdentity"] + resources = ["*"] + } + + statement { + sid = "ReadExactIamResources" + effect = "Allow" + actions = [ + "iam:GetInstanceProfile", + "iam:GetRole", + "iam:GetRolePolicy", + "iam:ListAttachedRolePolicies", + "iam:ListInstanceProfileTags", + "iam:ListInstanceProfilesForRole", + "iam:ListRolePolicies", + "iam:ListRoleTags", + ] + resources = [ + "arn:aws:iam::${local.account_id}:role/${each.value.deploy_role_name}", + "arn:aws:iam::${local.account_id}:role/${each.value.runtime_role_name}", + "arn:aws:iam::${local.account_id}:instance-profile/${each.value.instance_profile_name}", + local.eb_service_role_arn, + ] + } + + statement { + sid = "ReadGithubOidc" + effect = "Allow" + actions = ["iam:GetOpenIDConnectProvider"] + resources = [local.live_github_oidc_arn] + } + + statement { + sid = "ReadSharedInventory" + effect = "Allow" + actions = [ + "acm:ListCertificates", + "ec2:DescribeSecurityGroups", + "ec2:DescribeSubnets", + "ec2:DescribeVpcs", + "iam:ListOpenIDConnectProviders", + "rds:DescribeDBInstances", + "route53:ListHostedZonesByName", + ] + # These AWS read APIs do not support resource-level permissions. + resources = ["*"] + } + + statement { + sid = "ReadPinnedCertificate" + effect = "Allow" + actions = ["acm:DescribeCertificate", "acm:ListTagsForCertificate"] + resources = [local.live_certificate_arn] + } + + statement { + sid = "ReadSharedRdsTags" + effect = "Allow" + actions = ["rds:ListTagsForResource"] + resources = [local.live_rds_arn] + } + + statement { + sid = "ReadPinnedHostedZone" + effect = "Allow" + actions = ["route53:GetHostedZone", "route53:ListResourceRecordSets", "route53:ListTagsForResource"] + resources = ["arn:aws:route53:::hostedzone/${each.value.hosted_zone_id}"] + } +} + +data "aws_iam_policy_document" "live_apply" { + for_each = local.live_environments + source_policy_documents = [data.aws_iam_policy_document.live_plan[each.key].json] + + statement { + sid = "UpdateImportedDeployRole" + effect = "Allow" + actions = [ + "iam:PutRolePolicy", + "iam:TagRole", + "iam:UntagRole", + "iam:UpdateRole", + ] + resources = ["arn:aws:iam::${local.account_id}:role/${each.value.deploy_role_name}"] + } +} + +resource "aws_iam_role" "live_plan" { + for_each = local.live_environments + + name = "hcptf-shoc-backend-${each.key}-plan" + description = "Import/read-only HCP Terraform plan role for shoc-backend ${each.key}." + assume_role_policy = data.aws_iam_policy_document.live_plan_assume[each.key].json + max_session_duration = 3600 + permissions_boundary = var.execution_boundary_arn + + depends_on = [terraform_data.account_guard] + + lifecycle { + prevent_destroy = true + } +} + +resource "aws_iam_role_policy" "live_plan" { + for_each = local.live_environments + + name = "shoc-backend-${each.key}-import-plan" + role = aws_iam_role.live_plan[each.key].id + policy = data.aws_iam_policy_document.live_plan[each.key].json +} + +resource "aws_iam_role" "live_apply" { + for_each = local.live_environments + + name = "hcptf-shoc-backend-${each.key}" + description = "Import/update-only HCP Terraform apply role for shoc-backend ${each.key}." + assume_role_policy = data.aws_iam_policy_document.live_apply_assume[each.key].json + max_session_duration = 3600 + permissions_boundary = var.execution_boundary_arn + + depends_on = [terraform_data.account_guard] + + lifecycle { + prevent_destroy = true + } +} + +resource "aws_iam_role_policy" "live_apply" { + for_each = local.live_environments + + name = "shoc-backend-${each.key}-import-apply" + role = aws_iam_role.live_apply[each.key].id + policy = data.aws_iam_policy_document.live_apply[each.key].json +} diff --git a/terraform/bootstrap/locals.tf b/terraform/bootstrap/locals.tf new file mode 100644 index 0000000..d61bfb4 --- /dev/null +++ b/terraform/bootstrap/locals.tf @@ -0,0 +1,43 @@ +data "aws_caller_identity" "current" {} + +data "aws_iam_openid_connect_provider" "terraform_cloud" { + count = var.create_tfc_oidc_provider ? 0 : 1 + url = "https://app.terraform.io" +} + +locals { + account_id = data.aws_caller_identity.current.account_id + + hcp_sub_prefix = "organization:${var.hcp_organization}:project:${var.hcp_project}:workspace:${var.hcp_workload_workspace}:run_phase" + + tfc_oidc_arn = var.create_tfc_oidc_provider ? aws_iam_openid_connect_provider.terraform_cloud[0].arn : data.aws_iam_openid_connect_provider.terraform_cloud[0].arn + + github_deploy_role_arn = "arn:aws:iam::${local.account_id}:role/tf-managed/${var.github_deploy_role_name}" + eb_ec2_role_arn = "arn:aws:iam::${local.account_id}:role/tf-managed/${var.eb_ec2_role_name}" + github_oidc_arn = "arn:aws:iam::${local.account_id}:oidc-provider/token.actions.githubusercontent.com" + hosted_zone_arn = "arn:aws:route53:::hostedzone/${var.hosted_zone_id}" + eb_service_role_arn = "arn:aws:iam::${local.account_id}:role/${var.eb_service_role_name}" + + application_arn = "arn:aws:elasticbeanstalk:${var.aws_region}:${local.account_id}:application/${var.eb_application_name}" + environment_arn = "arn:aws:elasticbeanstalk:${var.aws_region}:${local.account_id}:environment/${var.eb_application_name}/${var.eb_environment_name}" + rds_arn = "arn:aws:rds:${var.aws_region}:${local.account_id}:db:${var.rds_identifier}" + + live_protected_role_arns = [ + "arn:aws:iam::${local.account_id}:role/githubdeploy-shoc-backend-dev", + "arn:aws:iam::${local.account_id}:role/cdk-hnb659fds-*", + "arn:aws:iam::${local.account_id}:role/OrganizationAccountAccessRole", + "arn:aws:iam::${local.account_id}:role/shoc-backend-dev", + ] + + live_eb_environment_arn = "arn:aws:elasticbeanstalk:${var.aws_region}:${local.account_id}:environment/shoc-backend/shoc-backend-dev" + live_cfn_stack_arn = "arn:aws:cloudformation:${var.aws_region}:${local.account_id}:stack/awseb-e-hehnrqjjrt-stack/*" +} + +resource "terraform_data" "account_guard" { + lifecycle { + precondition { + condition = local.account_id == var.aws_account_id + error_message = "Refuse to apply outside seahaven-external-dev (${var.aws_account_id}). Caller is ${local.account_id}." + } + } +} diff --git a/terraform/bootstrap/outputs.tf b/terraform/bootstrap/outputs.tf new file mode 100644 index 0000000..0a5d471 --- /dev/null +++ b/terraform/bootstrap/outputs.tf @@ -0,0 +1,29 @@ +output "tfc_oidc_provider_arn" { + description = "app.terraform.io OIDC provider ARN" + value = local.tfc_oidc_arn +} + +output "hcp_plan_role_arn" { + description = "Set TFC_AWS_PLAN_ROLE_ARN on workspace shoc-backend-tf-poc" + value = aws_iam_role.hcp_plan.arn +} + +output "hcp_apply_role_arn" { + description = "Set TFC_AWS_APPLY_ROLE_ARN on workspace shoc-backend-tf-poc" + value = aws_iam_role.hcp_apply.arn +} + +output "create_tfc_oidc_provider" { + description = "Whether this bootstrap created the OIDC provider (false means it was data-sourced)" + value = var.create_tfc_oidc_provider +} + +output "live_workspace_roles" { + description = "HCP Terraform dynamic credential roles for dev and staging." + value = { + for environment in keys(local.live_environments) : environment => { + plan_role_arn = aws_iam_role.live_plan[environment].arn + apply_role_arn = aws_iam_role.live_apply[environment].arn + } + } +} diff --git a/terraform/bootstrap/providers.tf b/terraform/bootstrap/providers.tf new file mode 100644 index 0000000..d1c0cd6 --- /dev/null +++ b/terraform/bootstrap/providers.tf @@ -0,0 +1,11 @@ +provider "aws" { + region = var.aws_region + + default_tags { + tags = { + Project = "shoc-backend" + ManagedBy = "terraform" + Workspace = "shoc-backend-bootstrap" + } + } +} diff --git a/terraform/bootstrap/terraform.tfvars.example b/terraform/bootstrap/terraform.tfvars.example new file mode 100644 index 0000000..2a060d2 --- /dev/null +++ b/terraform/bootstrap/terraform.tfvars.example @@ -0,0 +1,15 @@ +# Copy to terraform.tfvars for local apply. Defaults already match +# seahaven-external-dev; this file documents the locked names. +aws_region = "us-east-1" +aws_account_id = "396287094661" +hcp_organization = "seahaven" +hcp_project = "seahaven-external-dev" +hcp_workload_workspace = "shoc-backend-tf-poc" +create_tfc_oidc_provider = false +github_deploy_role_name = "githubdeploy-shoc-backend-tf-poc" +eb_ec2_role_name = "shoc-backend-tf-poc-ec2" +eb_application_name = "shoc-backend-tf-poc" +eb_environment_name = "shoc-backend-tf-poc" +rds_identifier = "shoc-backend-tf-poc" +hosted_zone_id = "Z07671212N75U4YLPWZR8" +eb_service_role_name = "shoc-eb-service-role" diff --git a/terraform/bootstrap/variables.tf b/terraform/bootstrap/variables.tf new file mode 100644 index 0000000..f8bc09d --- /dev/null +++ b/terraform/bootstrap/variables.tf @@ -0,0 +1,88 @@ +variable "aws_region" { + type = string + description = "AWS region for IAM (global) and any regional data sources" + default = "us-east-1" +} + +variable "aws_account_id" { + type = string + description = "seahaven-external-dev. Apply refuses any other account." + default = "396287094661" + + validation { + condition = var.aws_account_id == "396287094661" + error_message = "This bootstrap is only for seahaven-external-dev (396287094661)." + } +} + +variable "hcp_organization" { + type = string + description = "HCP Terraform organization name in OIDC trust subs" + default = "seahaven" +} + +variable "hcp_project" { + type = string + description = "HCP Terraform project name in OIDC trust subs" + default = "seahaven-external-dev" +} + +variable "hcp_workload_workspace" { + type = string + description = "Workload workspace the plan/apply roles trust (not this bootstrap workspace)" + default = "shoc-backend-tf-poc" +} + +variable "create_tfc_oidc_provider" { + type = bool + description = "Create the account-global app.terraform.io OIDC provider. Default false: import the provider the frontend POC already created. Set true only if that data source fails." + default = false +} + +variable "github_deploy_role_name" { + type = string + description = "GitHub OIDC deploy role name (path /tf-managed/ is fixed in IAM ARNs)" + default = "githubdeploy-shoc-backend-tf-poc" +} + +variable "eb_ec2_role_name" { + type = string + description = "Elastic Beanstalk instance role name (path /tf-managed/)" + default = "shoc-backend-tf-poc-ec2" +} + +variable "eb_application_name" { + type = string + description = "POC Elastic Beanstalk application the apply role may manage" + default = "shoc-backend-tf-poc" +} + +variable "eb_environment_name" { + type = string + description = "POC Elastic Beanstalk environment the apply role may manage" + default = "shoc-backend-tf-poc" +} + +variable "rds_identifier" { + type = string + description = "POC RDS instance identifier the apply role may manage" + default = "shoc-backend-tf-poc" +} + +variable "hosted_zone_id" { + type = string + description = "dev.seahaven.com zone; apply may change records here (tf-poc + ACM validation only in workload TF)" + default = "Z07671212N75U4YLPWZR8" +} + +variable "execution_boundary_arn" { + type = string + description = "SCP-required permissions boundary for CreateRole in this account" + default = "arn:aws:iam::396287094661:policy/external-dev-execution-boundary" +} + +variable "eb_service_role_name" { + type = string + description = "Existing Elastic Beanstalk service role to PassRole (not created by this stack)" + default = "shoc-eb-service-role" +} diff --git a/terraform/bootstrap/versions.tf b/terraform/bootstrap/versions.tf new file mode 100644 index 0000000..5e7bd96 --- /dev/null +++ b/terraform/bootstrap/versions.tf @@ -0,0 +1,18 @@ +terraform { + required_version = ">= 1.7.0" + + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 6.57" + } + } + + cloud { + organization = "seahaven" + + workspaces { + name = "shoc-backend-bootstrap" + } + } +} diff --git a/terraform/elastic_beanstalk.tf b/terraform/elastic_beanstalk.tf new file mode 100644 index 0000000..4f84a20 --- /dev/null +++ b/terraform/elastic_beanstalk.tf @@ -0,0 +1,234 @@ +resource "aws_elastic_beanstalk_application" "api" { + name = var.eb_application_name + description = "SHOC API ${var.environment} (Terraform). Parallel to live shoc-backend during the POC." + + tags = { + Name = var.eb_application_name + } + + depends_on = [terraform_data.account_guard] +} + +resource "aws_elastic_beanstalk_environment" "api" { + name = var.eb_environment_name + application = aws_elastic_beanstalk_application.api.name + solution_stack_name = data.aws_elastic_beanstalk_solution_stack.dotnet.name + tier = "WebServer" + cname_prefix = var.eb_environment_name + + wait_for_ready_timeout = "40m" + + setting { + namespace = "aws:elasticbeanstalk:environment" + name = "EnvironmentType" + value = "LoadBalanced" + } + + setting { + namespace = "aws:elasticbeanstalk:environment" + name = "LoadBalancerType" + value = "application" + } + + setting { + namespace = "aws:elasticbeanstalk:environment" + name = "ServiceRole" + value = data.aws_iam_role.eb_service.arn + } + + setting { + namespace = "aws:ec2:vpc" + name = "VPCId" + value = var.vpc_id + } + + setting { + namespace = "aws:ec2:vpc" + name = "Subnets" + value = join(",", var.private_subnet_ids) + } + + setting { + namespace = "aws:ec2:vpc" + name = "ELBSubnets" + value = join(",", var.public_subnet_ids) + } + + setting { + namespace = "aws:ec2:vpc" + name = "ELBScheme" + value = "public" + } + + setting { + namespace = "aws:ec2:vpc" + name = "AssociatePublicIpAddress" + value = var.associate_public_ip ? "true" : "false" + } + + setting { + namespace = "aws:autoscaling:launchconfiguration" + name = "IamInstanceProfile" + value = aws_iam_instance_profile.eb_ec2.name + } + + setting { + namespace = "aws:autoscaling:launchconfiguration" + name = "InstanceType" + value = var.eb_instance_type + } + + setting { + namespace = "aws:autoscaling:launchconfiguration" + name = "SecurityGroups" + value = aws_security_group.eb.id + } + + setting { + namespace = "aws:autoscaling:launchconfiguration" + name = "DisableIMDSv1" + value = "true" + } + + setting { + namespace = "aws:autoscaling:asg" + name = "MinSize" + value = "1" + } + + setting { + namespace = "aws:autoscaling:asg" + name = "MaxSize" + value = "1" + } + + setting { + namespace = "aws:elbv2:loadbalancer" + name = "SecurityGroups" + value = aws_security_group.alb.id + } + + setting { + namespace = "aws:elbv2:loadbalancer" + name = "ManagedSecurityGroup" + value = aws_security_group.alb.id + } + + setting { + namespace = "aws:elbv2:listener:443" + name = "Protocol" + value = "HTTPS" + } + + setting { + namespace = "aws:elbv2:listener:443" + name = "SSLCertificateArns" + value = aws_acm_certificate_validation.api.certificate_arn + } + + setting { + namespace = "aws:elbv2:listener:443" + name = "SSLPolicy" + value = "ELBSecurityPolicy-TLS13-1-2-2021-06" + } + + setting { + namespace = "aws:elbv2:listener:80" + name = "Protocol" + value = "HTTP" + } + + setting { + namespace = "aws:elasticbeanstalk:environment:process:default" + name = "HealthCheckPath" + value = "/swagger/v1/swagger.json" + } + + setting { + namespace = "aws:elasticbeanstalk:environment:process:default" + name = "Port" + value = "80" + } + + setting { + namespace = "aws:elasticbeanstalk:application:environment" + name = "ASPNETCORE_ENVIRONMENT" + value = "Production" + } + + setting { + namespace = "aws:elasticbeanstalk:application:environment" + name = "ConnectionStrings__DefaultConnection" + value = local.connection_string + } + + setting { + namespace = "aws:elasticbeanstalk:application:environment" + name = "JWT__Secret" + value = aws_secretsmanager_secret_version.jwt.secret_string + } + + setting { + namespace = "aws:elasticbeanstalk:application:environment" + name = "SendGrid__ApiKey" + value = var.sendgrid_api_key + } + + setting { + namespace = "aws:elasticbeanstalk:application:environment" + name = "WorkOrderWebhook__Enabled" + value = "true" + } + + setting { + namespace = "aws:elasticbeanstalk:application:environment" + name = "WorkOrderWebhook__Region" + value = var.aws_region + } + + setting { + namespace = "aws:elasticbeanstalk:application:environment" + name = "WorkOrderWebhook__SecretId" + value = aws_secretsmanager_secret.webhook.arn + } + + setting { + namespace = "aws:elasticbeanstalk:application:environment" + name = "Sync__Enabled" + value = "false" + } + + setting { + namespace = "aws:elasticbeanstalk:application:environment" + name = "WorkOrderReconciliation__Enabled" + value = "false" + } + + setting { + namespace = "aws:elasticbeanstalk:command" + name = "DeploymentPolicy" + value = "AllAtOnce" + } + + tags = { + Name = var.eb_environment_name + } + + depends_on = [ + aws_iam_role_policy_attachment.eb_web_tier, + aws_iam_instance_profile.eb_ec2, + aws_db_instance.poc, + ] +} + +resource "aws_route53_record" "api" { + zone_id = var.hosted_zone_id + name = var.domain_name + type = "A" + + alias { + name = aws_elastic_beanstalk_environment.api.cname + zone_id = data.aws_elastic_beanstalk_hosted_zone.current.id + evaluate_target_health = true + } +} diff --git a/terraform/iam_github_deploy.tf b/terraform/iam_github_deploy.tf new file mode 100644 index 0000000..0f3970a --- /dev/null +++ b/terraform/iam_github_deploy.tf @@ -0,0 +1,145 @@ +data "aws_iam_policy_document" "github_deploy_assume" { + statement { + effect = "Allow" + actions = ["sts:AssumeRoleWithWebIdentity"] + + principals { + type = "Federated" + identifiers = [data.aws_iam_openid_connect_provider.github.arn] + } + + condition { + test = "StringEquals" + variable = "token.actions.githubusercontent.com:aud" + values = ["sts.amazonaws.com"] + } + + condition { + test = "StringEquals" + variable = "token.actions.githubusercontent.com:sub" + values = ["repo:${var.github_repo}:environment:${var.github_environment}"] + } + } +} + +resource "aws_iam_role" "github_deploy" { + name = var.github_deploy_role_name + path = local.content_role_path + description = "GitHub Actions deploy role for ${var.github_repo} environment ${var.github_environment}" + assume_role_policy = data.aws_iam_policy_document.github_deploy_assume.json + max_session_duration = 3600 + permissions_boundary = var.execution_boundary_arn + + lifecycle { + ignore_changes = [permissions_boundary] + } + + depends_on = [terraform_data.account_guard] +} + +locals { + poc_environment_id = aws_elastic_beanstalk_environment.api.id + poc_environment_stack = "awseb-${aws_elastic_beanstalk_environment.api.id}-stack" + poc_application_arn = "arn:aws:elasticbeanstalk:${var.aws_region}:${local.account_id}:application/${var.eb_application_name}" + poc_environment_arn = "arn:aws:elasticbeanstalk:${var.aws_region}:${local.account_id}:environment/${var.eb_application_name}/${var.eb_environment_name}" +} + +data "aws_iam_policy_document" "github_deploy" { + statement { + sid = "DescribeDiscovery" + effect = "Allow" + actions = [ + "autoscaling:Describe*", + "ec2:Describe*", + "elasticbeanstalk:DescribeEnvironments", + "elasticbeanstalk:DescribeApplicationVersions", + "elasticbeanstalk:DescribeEvents", + "elasticloadbalancing:Describe*", + ] + resources = ["*"] + } + + statement { + sid = "CreateApplicationVersion" + effect = "Allow" + actions = [ + "elasticbeanstalk:CreateApplicationVersion", + ] + resources = [ + local.poc_application_arn, + "arn:aws:elasticbeanstalk:${var.aws_region}:${local.account_id}:applicationversion/${var.eb_application_name}/*", + ] + } + + statement { + sid = "UpdatePocEnvironment" + effect = "Allow" + actions = ["elasticbeanstalk:UpdateEnvironment"] + resources = [local.poc_environment_arn] + } + + statement { + sid = "PocManagedCfn" + effect = "Allow" + actions = [ + "cloudformation:DescribeStackEvents", + "cloudformation:DescribeStackResource", + "cloudformation:GetTemplate", + "cloudformation:DescribeStackResources", + "cloudformation:DescribeStacks", + "cloudformation:ListStackResources", + "cloudformation:CancelUpdateStack", + "cloudformation:UpdateStack", + ] + resources = [ + "arn:aws:cloudformation:${var.aws_region}:${local.account_id}:stack/${local.poc_environment_stack}/*", + ] + } + + statement { + sid = "PocAsgProcess" + effect = "Allow" + actions = [ + "autoscaling:PutNotificationConfiguration", + "autoscaling:ResumeProcesses", + "autoscaling:SuspendProcesses", + ] + resources = [ + "arn:aws:autoscaling:${var.aws_region}:${local.account_id}:autoScalingGroup:*:autoScalingGroupName/${local.poc_environment_stack}-*", + ] + } + + statement { + sid = "EbServiceObjects" + effect = "Allow" + actions = ["s3:PutObject"] + resources = [ + "arn:aws:s3:::elasticbeanstalk-${var.aws_region}-${local.account_id}/${var.eb_application_name}/*", + ] + } + + statement { + sid = "EbServiceBuckets" + effect = "Allow" + actions = [ + "s3:GetBucketLocation", + "s3:ListBucket", + ] + resources = ["arn:aws:s3:::elasticbeanstalk-${var.aws_region}-${local.account_id}"] + } + + statement { + sid = "DenyLiveEnvironment" + effect = "Deny" + actions = ["elasticbeanstalk:*"] + resources = [ + "arn:aws:elasticbeanstalk:${var.aws_region}:${local.account_id}:environment/shoc-backend/shoc-backend-dev", + ] + } +} + +resource "aws_iam_role_policy" "github_deploy" { + name = "${var.github_deploy_role_name}-eb" + role = aws_iam_role.github_deploy.id + policy = data.aws_iam_policy_document.github_deploy.json +} diff --git a/terraform/iam_runtime.tf b/terraform/iam_runtime.tf new file mode 100644 index 0000000..710efb7 --- /dev/null +++ b/terraform/iam_runtime.tf @@ -0,0 +1,69 @@ +data "aws_iam_policy_document" "eb_ec2_assume" { + statement { + effect = "Allow" + actions = ["sts:AssumeRole"] + + principals { + type = "Service" + identifiers = ["ec2.amazonaws.com"] + } + } +} + +resource "aws_iam_role" "eb_ec2" { + name = var.eb_ec2_role_name + path = local.content_role_path + description = "Elastic Beanstalk instance role for ${var.eb_environment_name}" + assume_role_policy = data.aws_iam_policy_document.eb_ec2_assume.json + max_session_duration = 3600 + permissions_boundary = var.execution_boundary_arn + + lifecycle { + ignore_changes = [permissions_boundary] + } + + depends_on = [terraform_data.account_guard] +} + +resource "aws_iam_role_policy_attachment" "eb_web_tier" { + role = aws_iam_role.eb_ec2.name + policy_arn = "arn:aws:iam::aws:policy/AWSElasticBeanstalkWebTier" +} + +resource "aws_iam_role_policy_attachment" "eb_worker_tier" { + role = aws_iam_role.eb_ec2.name + policy_arn = "arn:aws:iam::aws:policy/AWSElasticBeanstalkWorkerTier" +} + +resource "aws_iam_role_policy_attachment" "eb_ssm" { + role = aws_iam_role.eb_ec2.name + policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore" +} + +data "aws_iam_policy_document" "eb_ec2_secrets" { + statement { + sid = "PocSecrets" + effect = "Allow" + actions = [ + "secretsmanager:GetSecretValue", + "secretsmanager:DescribeSecret", + ] + resources = [ + aws_secretsmanager_secret.jwt.arn, + aws_secretsmanager_secret.webhook.arn, + aws_db_instance.poc.master_user_secret[0].secret_arn, + ] + } +} + +resource "aws_iam_role_policy" "eb_ec2_secrets" { + name = "shoc-backend-tf-poc-secrets" + role = aws_iam_role.eb_ec2.id + policy = data.aws_iam_policy_document.eb_ec2_secrets.json +} + +resource "aws_iam_instance_profile" "eb_ec2" { + name = var.eb_ec2_role_name + path = local.content_role_path + role = aws_iam_role.eb_ec2.name +} diff --git a/terraform/live/README.md b/terraform/live/README.md new file mode 100644 index 0000000..249939d --- /dev/null +++ b/terraform/live/README.md @@ -0,0 +1,73 @@ +# Live deploy-role adoption + +These roots replace CDK ownership of the existing GitHub Actions deploy roles. +They do not create or manage Elastic Beanstalk, RDS, ACM, Route 53, VPC, +subnets, security groups, runtime roles, instance profiles, or secrets. + +## Ownership + +- `dev/` imports `githubdeploy-shoc-backend-dev` and its existing inline policy. +- `staging/` imports `githubdeploy-shoc-backend-staging` and its existing inline + policy. +- `modules/environment-inventory/` reads and pins shared and environment + resources without owning them. +- `../bootstrap/` owns the four narrowly scoped live HCP Terraform plan/apply + roles alongside the temporary POC role pair. + +The shared `shoc-backend` Elastic Beanstalk application and +`shoc-sqlserver-shared` RDS instance must never enter either environment state. + +## Two-phase adoption + +Each live root pins `adoption_complete=false` in reviewed code. It is not an +HCP workspace variable. + +1. Create the HCP workspace and configure dynamic credentials. +2. Run the declarative imports. +3. Export the HCP plan as JSON and run: + + ```bash + python scripts/check-terraform-import-plan.py plan.json + ``` + + The first plan must be a no-op after import. The guard rejects updates, + creates, deletes, replacements, and managed resource types outside the + deploy role and inline policy. +4. Apply the no-op import only after review. +5. Change the environment root to `adoption_complete=true` in a reviewed code + change, then review the controlled in-place role and policy update: + + ```bash + python scripts/check-terraform-import-plan.py plan.json --allow-update + ``` + +6. Apply only when the plan contains updates to the imported deploy role and + policy, with no create, delete, or replacement actions. + +The reviewed `adoption_complete=true` change updates the ownership +tag/description and narrows the dev role to the staging-style S3 bucket and +application prefix. Read-only AWS APIs retain `Resource = "*"` only where AWS +does not support resource-level permissions. + +## Pinned live identities + +- Dev: workspace `shoc-backend-dev`; EB environment `shoc-backend-dev` + (`e-hehnrqjjrt`); .NET 8 AL2023 `3.11.3`; `api.dev.seahaven.com`. +- Staging: workspace `shoc-backend-staging`; EB environment + `shoc-backend-staging` (`e-6c9m4vb62z`); .NET 8 AL2023 `3.11.3`; + `api.staging.seahaven.com`. + +The environment roots are intentionally not general-purpose modules. Exact +identifiers make accidental cross-environment reuse fail review and planning. + +## Safety invariants + +- Never reuse `shoc-backend-tf-poc` state. +- Auto-apply remains off. +- HCP apply roles have no IAM create/delete permissions and no service + mutation permissions outside the exact imported deploy role. +- Both managed resources have `prevent_destroy`. +- Do not retire the CDK stack until the no-op import and controlled policy + update have both succeeded. +- Do not destroy the POC workload until dev and staging deployment smoke tests + have stabilized. diff --git a/terraform/live/dev/.terraform.lock.hcl b/terraform/live/dev/.terraform.lock.hcl new file mode 100644 index 0000000..479237a --- /dev/null +++ b/terraform/live/dev/.terraform.lock.hcl @@ -0,0 +1,26 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/aws" { + version = "6.62.0" + constraints = "~> 6.57" + hashes = [ + "h1:OthB9UeoBgmy348EpDjs5GDGk6p6UxAMQD5cXn7u9Ho=", + "zh:35a9e4bc6fd622c5a99561b882025f2745f1256bbf1a8da8d6b39319b75ae0b5", + "zh:405927d470ff16201e40aa0fa2d0ab1de477360a0926d20719cd029179682ecd", + "zh:4ab7866593a90bcf18f066b0092a209b9f42852acd783b504031ae74cb6f7010", + "zh:5b477f313fc511648a4eed9f9085d0778414835896256ab14296d2345b7070e3", + "zh:87de70bc99751f94262cec2260d972555a98f588aa3a613e417438f88a1182df", + "zh:88f02a8ff07f00da4ffb3bee9e8ae25588e3a0a92633c625c1c2a63bac00a844", + "zh:8d8596257453357c9f3fccaa7d2f04299e8d35b16f364adb8a2c829143a9c090", + "zh:953c8e15fa9c12c081f17d66cf45246d032fa23bee33f264dc82242afdb98bc2", + "zh:9a7dd903e5e9b2b0cc1317ad2d2692e0ddf05ae2a5aaee20ec5dd1db456711b7", + "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425", + "zh:a859154c75c1088d098a481f1ebb259720c5a2ad87781364abf556a741e5adb7", + "zh:b8d1e72ad39d5864118f64dd3273424ab637d34b3ff8dd3dfeb4aef9d458587f", + "zh:c3666fcfc7b131f5282d4e7249fa68c3a21665757888aa02bbaf6be1cd036bba", + "zh:c6b8ff94b3f49bf85fc087381cfe1b271c5b01cf74cf140d58aa500be7138913", + "zh:d8143d790e9dd77b8e2f9168e4a33ad6d064dc4b082a0196636b182105aaed14", + "zh:fa41eca042f377eb2741e95b36609c1de5b0cd675cd4e3e30c709497cb94db02", + ] +} diff --git a/terraform/live/dev/imports.tf b/terraform/live/dev/imports.tf new file mode 100644 index 0000000..39ab4f7 --- /dev/null +++ b/terraform/live/dev/imports.tf @@ -0,0 +1,9 @@ +import { + to = module.deploy_role.aws_iam_role.github_deploy + id = "githubdeploy-shoc-backend-dev" +} + +import { + to = module.deploy_role.aws_iam_role_policy.github_deploy + id = "githubdeploy-shoc-backend-dev:GithubDeployRoleDefaultPolicyE8F540D1" +} diff --git a/terraform/live/dev/main.tf b/terraform/live/dev/main.tf new file mode 100644 index 0000000..28210d4 --- /dev/null +++ b/terraform/live/dev/main.tf @@ -0,0 +1,42 @@ +locals { + aws_account_id = "396287094661" + aws_region = "us-east-1" + + eb_application_name = "shoc-backend" + eb_environment_name = "shoc-backend-dev" + eb_environment_id = "e-hehnrqjjrt" + eb_platform = "64bit Amazon Linux 2023 v3.11.3 running .NET 8" + api_domain = "api.dev.seahaven.com" +} + +module "inventory" { + source = "../modules/environment-inventory" + + aws_account_id = local.aws_account_id + vpc_id = "vpc-0d16336143f3da25e" + subnet_ids = ["subnet-09eaf2bfa468d206f", "subnet-02946ccd6735742e9", "subnet-0bedaa6e0c750be4f"] + rds_identifier = "shoc-sqlserver-shared" + eb_service_role_name = "shoc-eb-service-role" + runtime_role_name = "shoc-backend-dev" + instance_profile_name = "shoc-backend-dev" + security_group_ids = ["sg-0c8bb7cf2c193de57", "sg-050e5a737e98ba699"] + certificate_domain = "*.seahaven.com" + expected_certificate_arn = "arn:aws:acm:us-east-1:396287094661:certificate/2b78e74f-7b65-4b82-a413-7a498b102f00" + hosted_zone_name = "dev.seahaven.com" + expected_hosted_zone_id = "Z07671212N75U4YLPWZR8" +} + +module "deploy_role" { + source = "../modules/deploy-role" + + aws_account_id = local.aws_account_id + aws_region = local.aws_region + github_repo = "Sea-Haven-Industries/shoc-backend" + environment = "dev" + eb_application_name = local.eb_application_name + eb_environment_name = local.eb_environment_name + eb_environment_id = local.eb_environment_id + github_deploy_role_name = "githubdeploy-shoc-backend-dev" + adoption_complete = false + legacy_dev_s3_policy = true +} diff --git a/terraform/live/dev/outputs.tf b/terraform/live/dev/outputs.tf new file mode 100644 index 0000000..ed96849 --- /dev/null +++ b/terraform/live/dev/outputs.tf @@ -0,0 +1,20 @@ +output "github_deploy_role_arn" { + description = "Existing dev GitHub deploy role ARN." + value = module.deploy_role.role_arn +} + +output "shared_rds_arn" { + description = "Data-sourced shared RDS ARN." + value = module.inventory.shared_rds_arn +} + +output "pinned_eb_environment" { + description = "Pinned existing dev Elastic Beanstalk environment identity." + value = { + application = local.eb_application_name + environment = local.eb_environment_name + id = local.eb_environment_id + platform = local.eb_platform + api_domain = local.api_domain + } +} diff --git a/terraform/live/dev/providers.tf b/terraform/live/dev/providers.tf new file mode 100644 index 0000000..c125940 --- /dev/null +++ b/terraform/live/dev/providers.tf @@ -0,0 +1,3 @@ +provider "aws" { + region = "us-east-1" +} diff --git a/terraform/live/dev/versions.tf b/terraform/live/dev/versions.tf new file mode 100644 index 0000000..e7f7913 --- /dev/null +++ b/terraform/live/dev/versions.tf @@ -0,0 +1,19 @@ +terraform { + required_version = ">= 1.7.0" + + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 6.57" + } + } + + cloud { + organization = "seahaven" + + workspaces { + project = "seahaven-external-dev" + name = "shoc-backend-dev" + } + } +} diff --git a/terraform/live/modules/deploy-role/main.tf b/terraform/live/modules/deploy-role/main.tf new file mode 100644 index 0000000..a92d473 --- /dev/null +++ b/terraform/live/modules/deploy-role/main.tf @@ -0,0 +1,173 @@ +data "aws_iam_openid_connect_provider" "github" { + url = "https://token.actions.githubusercontent.com" +} + +locals { + application_arn = "arn:aws:elasticbeanstalk:${var.aws_region}:${var.aws_account_id}:application/${var.eb_application_name}" + environment_arn = "arn:aws:elasticbeanstalk:${var.aws_region}:${var.aws_account_id}:environment/${var.eb_application_name}/${var.eb_environment_name}" + environment_stack_name = "awseb-${var.eb_environment_id}-stack" + environment_stack_arn = "arn:aws:cloudformation:${var.aws_region}:${var.aws_account_id}:stack/${local.environment_stack_name}/*" + environment_asg_arn = "arn:aws:autoscaling:${var.aws_region}:${var.aws_account_id}:autoScalingGroup:*:autoScalingGroupName/${local.environment_stack_name}-*" + eb_bucket_name = "elasticbeanstalk-${var.aws_region}-${var.aws_account_id}" + use_legacy_s3_policy = !var.adoption_complete && var.legacy_dev_s3_policy +} + +data "aws_iam_policy_document" "assume" { + statement { + effect = "Allow" + actions = ["sts:AssumeRoleWithWebIdentity"] + + principals { + type = "Federated" + identifiers = [data.aws_iam_openid_connect_provider.github.arn] + } + + condition { + test = "StringEquals" + variable = "token.actions.githubusercontent.com:aud" + values = ["sts.amazonaws.com"] + } + + condition { + test = "StringEquals" + variable = "token.actions.githubusercontent.com:sub" + values = ["repo:${var.github_repo}:environment:${var.environment}"] + } + } +} + +resource "aws_iam_role" "github_deploy" { + name = var.github_deploy_role_name + path = "/" + description = var.adoption_complete ? ( + "Least-privilege GitHub OIDC deploy role for shoc-backend ${var.environment}. Terraform-owned; application/environment/S3 are owned by Elastic Beanstalk." + ) : ( + "Least-privilege GitHub OIDC deploy role for shoc-backend ${var.environment}. CDK-owned; application/environment/S3 are owned by Elastic Beanstalk." + ) + assume_role_policy = data.aws_iam_policy_document.assume.json + max_session_duration = 3600 + + tags = { + Component = "deploy-role" + Environment = var.environment + ManagedBy = var.adoption_complete ? "terraform" : "cdk" + Project = "shoc-backend" + } + + lifecycle { + prevent_destroy = true + } +} + +data "aws_iam_policy_document" "deploy" { + statement { + effect = "Allow" + actions = [ + "autoscaling:Describe*", + "ec2:Describe*", + "elasticbeanstalk:DescribeApplicationVersions", + "elasticbeanstalk:DescribeEnvironments", + "elasticbeanstalk:DescribeEvents", + "elasticloadbalancing:Describe*", + ] + # These AWS read APIs do not support resource-level permissions. + resources = ["*"] + } + + statement { + effect = "Allow" + actions = ["elasticbeanstalk:CreateApplicationVersion"] + resources = [ + local.application_arn, + "arn:aws:elasticbeanstalk:${var.aws_region}:${var.aws_account_id}:applicationversion/${var.eb_application_name}/*", + ] + } + + statement { + effect = "Allow" + actions = ["elasticbeanstalk:UpdateEnvironment"] + resources = [local.environment_arn] + } + + statement { + effect = "Allow" + actions = [ + "cloudformation:CancelUpdateStack", + "cloudformation:DescribeStackEvents", + "cloudformation:DescribeStackResource", + "cloudformation:DescribeStackResources", + "cloudformation:DescribeStacks", + "cloudformation:GetTemplate", + "cloudformation:ListStackResources", + "cloudformation:UpdateStack", + ] + resources = [local.environment_stack_arn] + } + + statement { + effect = "Allow" + actions = [ + "autoscaling:PutNotificationConfiguration", + "autoscaling:ResumeProcesses", + "autoscaling:SuspendProcesses", + ] + resources = [local.environment_asg_arn] + } + + dynamic "statement" { + for_each = local.use_legacy_s3_policy ? [1] : [] + content { + effect = "Allow" + actions = ["s3:Delete*", "s3:Get*", "s3:Put*"] + resources = [ + "arn:aws:s3:::elasticbeanstalk-*/*", + ] + } + } + + dynamic "statement" { + for_each = local.use_legacy_s3_policy ? [1] : [] + content { + effect = "Allow" + actions = [ + "s3:GetBucket*", + "s3:ListBucket", + "s3:PutBucketOwnershipControls", + "s3:PutBucketPolicy", + "s3:PutBucketPublicAccessBlock", + ] + resources = ["arn:aws:s3:::elasticbeanstalk-*"] + } + } + + dynamic "statement" { + for_each = local.use_legacy_s3_policy ? [] : [1] + content { + effect = "Allow" + actions = ["s3:PutObject"] + resources = ["arn:aws:s3:::${local.eb_bucket_name}/${var.eb_application_name}/*"] + } + } + + dynamic "statement" { + for_each = local.use_legacy_s3_policy ? [] : [1] + content { + effect = "Allow" + actions = [ + "s3:GetBucketLocation", + "s3:ListBucket", + ] + resources = ["arn:aws:s3:::${local.eb_bucket_name}"] + } + } +} + +resource "aws_iam_role_policy" "github_deploy" { + name = var.policy_name + role = aws_iam_role.github_deploy.id + policy = data.aws_iam_policy_document.deploy.json + + lifecycle { + prevent_destroy = true + } +} diff --git a/terraform/live/modules/deploy-role/outputs.tf b/terraform/live/modules/deploy-role/outputs.tf new file mode 100644 index 0000000..f373ae0 --- /dev/null +++ b/terraform/live/modules/deploy-role/outputs.tf @@ -0,0 +1,14 @@ +output "role_arn" { + description = "Existing GitHub deploy role ARN." + value = aws_iam_role.github_deploy.arn +} + +output "environment_arn" { + description = "Exact Elastic Beanstalk environment ARN the deploy role may update." + value = local.environment_arn +} + +output "environment_stack_arn" { + description = "Exact Elastic Beanstalk CloudFormation stack ARN pattern." + value = local.environment_stack_arn +} diff --git a/terraform/live/modules/deploy-role/variables.tf b/terraform/live/modules/deploy-role/variables.tf new file mode 100644 index 0000000..b0b1cd6 --- /dev/null +++ b/terraform/live/modules/deploy-role/variables.tf @@ -0,0 +1,62 @@ +variable "aws_account_id" { + type = string + description = "AWS account containing the existing deploy role." +} + +variable "aws_region" { + type = string + description = "AWS region containing the Elastic Beanstalk environment." +} + +variable "github_repo" { + type = string + description = "GitHub owner/repository allowed by the OIDC trust." +} + +variable "environment" { + type = string + description = "GitHub Environment and deployment environment." + + validation { + condition = contains(["dev", "staging"], var.environment) + error_message = "environment must be dev or staging." + } +} + +variable "eb_application_name" { + type = string + description = "Existing Elastic Beanstalk application name." +} + +variable "eb_environment_name" { + type = string + description = "Existing Elastic Beanstalk environment name." +} + +variable "eb_environment_id" { + type = string + description = "Existing Elastic Beanstalk environment ID used in generated resource names." +} + +variable "github_deploy_role_name" { + type = string + description = "Existing root-path GitHub OIDC deploy role name." +} + +variable "policy_name" { + type = string + description = "Existing inline policy name created by CDK." + default = "GithubDeployRoleDefaultPolicyE8F540D1" +} + +variable "adoption_complete" { + type = bool + description = "False preserves the current role exactly for a no-op import. True records Terraform ownership and applies the targeted S3 policy." + default = false +} + +variable "legacy_dev_s3_policy" { + type = bool + description = "Whether the current role has the legacy account-wide Elastic Beanstalk S3 permissions. Used only during the no-op import phase." + default = false +} diff --git a/terraform/live/modules/environment-inventory/main.tf b/terraform/live/modules/environment-inventory/main.tf new file mode 100644 index 0000000..6f6ccf4 --- /dev/null +++ b/terraform/live/modules/environment-inventory/main.tf @@ -0,0 +1,59 @@ +data "aws_caller_identity" "current" {} + +data "aws_vpc" "selected" { + id = var.vpc_id +} + +data "aws_subnet" "selected" { + for_each = var.subnet_ids + id = each.value +} + +data "aws_db_instance" "shared" { + db_instance_identifier = var.rds_identifier +} + +data "aws_iam_role" "eb_service" { + name = var.eb_service_role_name +} + +data "aws_iam_role" "runtime" { + name = var.runtime_role_name +} + +data "aws_iam_instance_profile" "runtime" { + name = var.instance_profile_name +} + +data "aws_security_group" "environment" { + for_each = var.security_group_ids + id = each.value +} + +data "aws_acm_certificate" "shared" { + domain = var.certificate_domain + statuses = ["ISSUED"] + most_recent = true +} + +data "aws_route53_zone" "api" { + name = var.hosted_zone_name + private_zone = false +} + +check "identity" { + assert { + condition = data.aws_caller_identity.current.account_id == var.aws_account_id + error_message = "Refusing to inspect resources outside the expected AWS account." + } + + assert { + condition = data.aws_acm_certificate.shared.arn == var.expected_certificate_arn + error_message = "The resolved ACM certificate does not match the pinned live certificate." + } + + assert { + condition = data.aws_route53_zone.api.zone_id == var.expected_hosted_zone_id + error_message = "The resolved Route 53 zone does not match the pinned live zone." + } +} diff --git a/terraform/live/modules/environment-inventory/outputs.tf b/terraform/live/modules/environment-inventory/outputs.tf new file mode 100644 index 0000000..9668ae9 --- /dev/null +++ b/terraform/live/modules/environment-inventory/outputs.tf @@ -0,0 +1,24 @@ +output "shared_rds_arn" { + description = "Existing shared RDS ARN. The live environment states never manage it." + value = data.aws_db_instance.shared.db_instance_arn +} + +output "runtime_role_arn" { + description = "Existing environment-specific runtime role ARN." + value = data.aws_iam_role.runtime.arn +} + +output "instance_profile_arn" { + description = "Existing environment-specific instance-profile ARN." + value = data.aws_iam_instance_profile.runtime.arn +} + +output "certificate_arn" { + description = "Pinned existing shared ACM certificate ARN." + value = data.aws_acm_certificate.shared.arn +} + +output "hosted_zone_id" { + description = "Pinned existing Route 53 hosted-zone ID." + value = data.aws_route53_zone.api.zone_id +} diff --git a/terraform/live/modules/environment-inventory/variables.tf b/terraform/live/modules/environment-inventory/variables.tf new file mode 100644 index 0000000..cc3defd --- /dev/null +++ b/terraform/live/modules/environment-inventory/variables.tf @@ -0,0 +1,59 @@ +variable "aws_account_id" { + type = string + description = "Expected AWS account ID." +} + +variable "vpc_id" { + type = string + description = "Existing VPC ID." +} + +variable "subnet_ids" { + type = set(string) + description = "Existing Elastic Beanstalk subnet IDs." +} + +variable "rds_identifier" { + type = string + description = "Existing shared RDS instance identifier." +} + +variable "eb_service_role_name" { + type = string + description = "Existing shared Elastic Beanstalk service role." +} + +variable "runtime_role_name" { + type = string + description = "Existing environment-specific EC2 role." +} + +variable "instance_profile_name" { + type = string + description = "Existing environment-specific EC2 instance profile." +} + +variable "security_group_ids" { + type = set(string) + description = "Existing environment-specific Elastic Beanstalk and load-balancer security groups." +} + +variable "certificate_domain" { + type = string + description = "Primary domain on the existing shared ACM certificate." +} + +variable "hosted_zone_name" { + type = string + description = "Existing Route 53 hosted-zone name." +} + +variable "expected_certificate_arn" { + type = string + description = "Exact existing ACM certificate ARN." +} + +variable "expected_hosted_zone_id" { + type = string + description = "Exact existing Route 53 hosted-zone ID." +} diff --git a/terraform/live/staging/.terraform.lock.hcl b/terraform/live/staging/.terraform.lock.hcl new file mode 100644 index 0000000..479237a --- /dev/null +++ b/terraform/live/staging/.terraform.lock.hcl @@ -0,0 +1,26 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/aws" { + version = "6.62.0" + constraints = "~> 6.57" + hashes = [ + "h1:OthB9UeoBgmy348EpDjs5GDGk6p6UxAMQD5cXn7u9Ho=", + "zh:35a9e4bc6fd622c5a99561b882025f2745f1256bbf1a8da8d6b39319b75ae0b5", + "zh:405927d470ff16201e40aa0fa2d0ab1de477360a0926d20719cd029179682ecd", + "zh:4ab7866593a90bcf18f066b0092a209b9f42852acd783b504031ae74cb6f7010", + "zh:5b477f313fc511648a4eed9f9085d0778414835896256ab14296d2345b7070e3", + "zh:87de70bc99751f94262cec2260d972555a98f588aa3a613e417438f88a1182df", + "zh:88f02a8ff07f00da4ffb3bee9e8ae25588e3a0a92633c625c1c2a63bac00a844", + "zh:8d8596257453357c9f3fccaa7d2f04299e8d35b16f364adb8a2c829143a9c090", + "zh:953c8e15fa9c12c081f17d66cf45246d032fa23bee33f264dc82242afdb98bc2", + "zh:9a7dd903e5e9b2b0cc1317ad2d2692e0ddf05ae2a5aaee20ec5dd1db456711b7", + "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425", + "zh:a859154c75c1088d098a481f1ebb259720c5a2ad87781364abf556a741e5adb7", + "zh:b8d1e72ad39d5864118f64dd3273424ab637d34b3ff8dd3dfeb4aef9d458587f", + "zh:c3666fcfc7b131f5282d4e7249fa68c3a21665757888aa02bbaf6be1cd036bba", + "zh:c6b8ff94b3f49bf85fc087381cfe1b271c5b01cf74cf140d58aa500be7138913", + "zh:d8143d790e9dd77b8e2f9168e4a33ad6d064dc4b082a0196636b182105aaed14", + "zh:fa41eca042f377eb2741e95b36609c1de5b0cd675cd4e3e30c709497cb94db02", + ] +} diff --git a/terraform/live/staging/imports.tf b/terraform/live/staging/imports.tf new file mode 100644 index 0000000..b4810ec --- /dev/null +++ b/terraform/live/staging/imports.tf @@ -0,0 +1,9 @@ +import { + to = module.deploy_role.aws_iam_role.github_deploy + id = "githubdeploy-shoc-backend-staging" +} + +import { + to = module.deploy_role.aws_iam_role_policy.github_deploy + id = "githubdeploy-shoc-backend-staging:GithubDeployRoleDefaultPolicyE8F540D1" +} diff --git a/terraform/live/staging/main.tf b/terraform/live/staging/main.tf new file mode 100644 index 0000000..14de03d --- /dev/null +++ b/terraform/live/staging/main.tf @@ -0,0 +1,42 @@ +locals { + aws_account_id = "396287094661" + aws_region = "us-east-1" + + eb_application_name = "shoc-backend" + eb_environment_name = "shoc-backend-staging" + eb_environment_id = "e-6c9m4vb62z" + eb_platform = "64bit Amazon Linux 2023 v3.11.3 running .NET 8" + api_domain = "api.staging.seahaven.com" +} + +module "inventory" { + source = "../modules/environment-inventory" + + aws_account_id = local.aws_account_id + vpc_id = "vpc-0d16336143f3da25e" + subnet_ids = ["subnet-09eaf2bfa468d206f", "subnet-02946ccd6735742e9", "subnet-0bedaa6e0c750be4f"] + rds_identifier = "shoc-sqlserver-shared" + eb_service_role_name = "shoc-eb-service-role" + runtime_role_name = "shoc-backend-staging" + instance_profile_name = "shoc-backend-staging" + security_group_ids = ["sg-02ea36a6719217fa2", "sg-0517062b0deef982d"] + certificate_domain = "*.seahaven.com" + expected_certificate_arn = "arn:aws:acm:us-east-1:396287094661:certificate/2b78e74f-7b65-4b82-a413-7a498b102f00" + hosted_zone_name = "staging.seahaven.com" + expected_hosted_zone_id = "Z02602739VQWBWCAGXP4" +} + +module "deploy_role" { + source = "../modules/deploy-role" + + aws_account_id = local.aws_account_id + aws_region = local.aws_region + github_repo = "Sea-Haven-Industries/shoc-backend" + environment = "staging" + eb_application_name = local.eb_application_name + eb_environment_name = local.eb_environment_name + eb_environment_id = local.eb_environment_id + github_deploy_role_name = "githubdeploy-shoc-backend-staging" + adoption_complete = false + legacy_dev_s3_policy = false +} diff --git a/terraform/live/staging/outputs.tf b/terraform/live/staging/outputs.tf new file mode 100644 index 0000000..31ceed4 --- /dev/null +++ b/terraform/live/staging/outputs.tf @@ -0,0 +1,20 @@ +output "github_deploy_role_arn" { + description = "Existing staging GitHub deploy role ARN." + value = module.deploy_role.role_arn +} + +output "shared_rds_arn" { + description = "Data-sourced shared RDS ARN." + value = module.inventory.shared_rds_arn +} + +output "pinned_eb_environment" { + description = "Pinned existing staging Elastic Beanstalk environment identity." + value = { + application = local.eb_application_name + environment = local.eb_environment_name + id = local.eb_environment_id + platform = local.eb_platform + api_domain = local.api_domain + } +} diff --git a/terraform/live/staging/providers.tf b/terraform/live/staging/providers.tf new file mode 100644 index 0000000..c125940 --- /dev/null +++ b/terraform/live/staging/providers.tf @@ -0,0 +1,3 @@ +provider "aws" { + region = "us-east-1" +} diff --git a/terraform/live/staging/versions.tf b/terraform/live/staging/versions.tf new file mode 100644 index 0000000..a6d93ec --- /dev/null +++ b/terraform/live/staging/versions.tf @@ -0,0 +1,19 @@ +terraform { + required_version = ">= 1.7.0" + + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 6.57" + } + } + + cloud { + organization = "seahaven" + + workspaces { + project = "seahaven-external-dev" + name = "shoc-backend-staging" + } + } +} diff --git a/terraform/locals.tf b/terraform/locals.tf new file mode 100644 index 0000000..3b65b97 --- /dev/null +++ b/terraform/locals.tf @@ -0,0 +1,48 @@ +data "aws_caller_identity" "current" {} + +data "aws_iam_openid_connect_provider" "github" { + url = "https://token.actions.githubusercontent.com" +} + +data "aws_iam_role" "eb_service" { + name = var.eb_service_role_name +} + +data "aws_elastic_beanstalk_solution_stack" "dotnet" { + most_recent = true + name_regex = "^64bit Amazon Linux 2023 .* running .NET 8$" +} + +data "aws_elastic_beanstalk_hosted_zone" "current" {} + +locals { + account_id = data.aws_caller_identity.current.account_id + content_role_path = "/tf-managed/" + + webhook_secret_name = "shoc-backend-tf-poc/webhook-hmac" + jwt_secret_name = "shoc-backend-tf-poc/jwt" +} + +resource "terraform_data" "account_guard" { + lifecycle { + precondition { + condition = local.account_id == var.aws_account_id + error_message = "Refuse to apply outside seahaven-external-dev (${var.aws_account_id}). Caller is ${local.account_id}." + } + + precondition { + condition = can(regex("^vpc-", var.vpc_id)) + error_message = "Set vpc_id from live Elastic Beanstalk discovery before apply." + } + + precondition { + condition = length(var.private_subnet_ids) >= 2 + error_message = "Set at least two private_subnet_ids before apply." + } + + precondition { + condition = length(var.public_subnet_ids) >= 2 + error_message = "Set at least two public_subnet_ids before apply." + } + } +} diff --git a/terraform/outputs.tf b/terraform/outputs.tf new file mode 100644 index 0000000..ea73431 --- /dev/null +++ b/terraform/outputs.tf @@ -0,0 +1,49 @@ +output "api_url" { + description = "Public URL of the POC API" + value = "https://${var.domain_name}" +} + +output "eb_application_name" { + description = "Elastic Beanstalk application name" + value = aws_elastic_beanstalk_application.api.name +} + +output "eb_environment_name" { + description = "Elastic Beanstalk environment name" + value = aws_elastic_beanstalk_environment.api.name +} + +output "eb_environment_id" { + description = "Elastic Beanstalk environment id (e-xxxxxxxx)" + value = aws_elastic_beanstalk_environment.api.id +} + +output "eb_cname" { + description = "Elastic Beanstalk environment CNAME" + value = aws_elastic_beanstalk_environment.api.cname +} + +output "rds_endpoint" { + description = "RDS SQL Server address" + value = aws_db_instance.poc.address +} + +output "database_name" { + description = "Catalog to CREATE DATABASE on the RDS instance before the first app deploy" + value = var.database_name +} + +output "webhook_secret_arn" { + description = "Secrets Manager ARN for the POC webhook HMAC keyset" + value = aws_secretsmanager_secret.webhook.arn +} + +output "github_deploy_role_arn" { + description = "Set GitHub Environment tf-poc secret AWS_DEPLOY_ROLE_ARN to this value" + value = aws_iam_role.github_deploy.arn +} + +output "acm_certificate_arn" { + description = "ACM certificate ARN for the POC hostname" + value = aws_acm_certificate.api.arn +} diff --git a/terraform/providers.tf b/terraform/providers.tf new file mode 100644 index 0000000..b4f077b --- /dev/null +++ b/terraform/providers.tf @@ -0,0 +1,12 @@ +provider "aws" { + region = var.aws_region + + default_tags { + tags = { + Project = "shoc-backend" + ManagedBy = "terraform" + Workspace = "shoc-backend-tf-poc" + Environment = var.environment + } + } +} diff --git a/terraform/rds.tf b/terraform/rds.tf new file mode 100644 index 0000000..c84f219 --- /dev/null +++ b/terraform/rds.tf @@ -0,0 +1,66 @@ +resource "aws_db_subnet_group" "poc" { + name = "shoc-backend-tf-poc" + subnet_ids = var.private_subnet_ids + + tags = { + Name = "shoc-backend-tf-poc" + } + + depends_on = [terraform_data.account_guard] +} + +resource "aws_db_instance" "poc" { + identifier = var.rds_identifier + engine = "sqlserver-ex" + instance_class = var.rds_instance_class + license_model = "license-included" + + allocated_storage = var.rds_allocated_storage + max_allocated_storage = var.rds_allocated_storage + storage_type = "gp3" + storage_encrypted = true + + username = var.rds_master_username + manage_master_user_password = true + + db_subnet_group_name = aws_db_subnet_group.poc.name + vpc_security_group_ids = [aws_security_group.rds.id] + publicly_accessible = false + multi_az = false + port = 1433 + + backup_retention_period = 1 + deletion_protection = false + skip_final_snapshot = true + apply_immediately = true + copy_tags_to_snapshot = true + + tags = { + Name = var.rds_identifier + } + + timeouts { + create = "90m" + update = "90m" + delete = "90m" + } +} + +data "aws_secretsmanager_secret_version" "rds_master" { + secret_id = aws_db_instance.poc.master_user_secret[0].secret_arn + depends_on = [aws_db_instance.poc] +} + +locals { + rds_master = jsondecode(data.aws_secretsmanager_secret_version.rds_master.secret_string) + + connection_string = join(";", [ + "Server=${aws_db_instance.poc.address},${aws_db_instance.poc.port}", + "Initial Catalog=${var.database_name}", + "User Id=${local.rds_master["username"]}", + "Password=${local.rds_master["password"]}", + "Encrypt=True", + "TrustServerCertificate=True", + "MultipleActiveResultSets=true", + ]) +} diff --git a/terraform/secrets.tf b/terraform/secrets.tf new file mode 100644 index 0000000..9636e36 --- /dev/null +++ b/terraform/secrets.tf @@ -0,0 +1,49 @@ +resource "random_password" "jwt" { + length = 64 + special = false +} + +resource "random_id" "webhook_secret" { + byte_length = 32 +} + +resource "aws_secretsmanager_secret" "jwt" { + name = local.jwt_secret_name + description = "JWT signing secret for shoc-backend-tf-poc. Rotate the secret version out of band; Terraform ignores later value changes." + recovery_window_in_days = 0 + + depends_on = [terraform_data.account_guard] +} + +resource "aws_secretsmanager_secret_version" "jwt" { + secret_id = aws_secretsmanager_secret.jwt.id + secret_string = random_password.jwt.result + + lifecycle { + ignore_changes = [secret_string] + } +} + +resource "aws_secretsmanager_secret" "webhook" { + name = local.webhook_secret_name + description = "Work-order webhook HMAC keyset for shoc-backend-tf-poc. Do not use the prod ingest ARN." + recovery_window_in_days = 0 + + depends_on = [terraform_data.account_guard] +} + +resource "aws_secretsmanager_secret_version" "webhook" { + secret_id = aws_secretsmanager_secret.webhook.id + secret_string = jsonencode({ + keys = [ + { + kid = "tf-poc" + secret = random_id.webhook_secret.hex + } + ] + }) + + lifecycle { + ignore_changes = [secret_string] + } +} diff --git a/terraform/security_groups.tf b/terraform/security_groups.tf new file mode 100644 index 0000000..a3ce2e5 --- /dev/null +++ b/terraform/security_groups.tf @@ -0,0 +1,93 @@ +resource "aws_security_group" "eb" { + name = "shoc-backend-tf-poc-eb" + description = "Elastic Beanstalk instances for shoc-backend-tf-poc" + vpc_id = var.vpc_id + + tags = { + Name = "shoc-backend-tf-poc-eb" + Project = "shoc-backend" + } + + depends_on = [terraform_data.account_guard] +} + +resource "aws_vpc_security_group_egress_rule" "eb_all" { + security_group_id = aws_security_group.eb.id + cidr_ipv4 = "0.0.0.0/0" + ip_protocol = "-1" + description = "Instances need outbound for Secrets Manager, Windows Update-style platform, and HTTPS." +} + +resource "aws_security_group" "alb" { + name = "shoc-backend-tf-poc-alb" + description = "Application load balancer for shoc-backend-tf-poc" + vpc_id = var.vpc_id + + tags = { + Name = "shoc-backend-tf-poc-alb" + Project = "shoc-backend" + } + + depends_on = [terraform_data.account_guard] +} + +resource "aws_vpc_security_group_ingress_rule" "alb_http" { + security_group_id = aws_security_group.alb.id + cidr_ipv4 = "0.0.0.0/0" + from_port = 80 + to_port = 80 + ip_protocol = "tcp" + description = "HTTP (redirected to HTTPS by the load balancer)" +} + +resource "aws_vpc_security_group_ingress_rule" "alb_https" { + security_group_id = aws_security_group.alb.id + cidr_ipv4 = "0.0.0.0/0" + from_port = 443 + to_port = 443 + ip_protocol = "tcp" + description = "HTTPS" +} + +resource "aws_vpc_security_group_egress_rule" "alb_all" { + security_group_id = aws_security_group.alb.id + cidr_ipv4 = "0.0.0.0/0" + ip_protocol = "-1" +} + +resource "aws_vpc_security_group_ingress_rule" "eb_from_alb" { + security_group_id = aws_security_group.eb.id + referenced_security_group_id = aws_security_group.alb.id + from_port = 80 + to_port = 80 + ip_protocol = "tcp" + description = "ALB to instance HTTP" +} + +resource "aws_security_group" "rds" { + name = "shoc-backend-tf-poc-rds" + description = "SQL Server for shoc-backend-tf-poc" + vpc_id = var.vpc_id + + tags = { + Name = "shoc-backend-tf-poc-rds" + Project = "shoc-backend" + } + + depends_on = [terraform_data.account_guard] +} + +resource "aws_vpc_security_group_ingress_rule" "rds_from_eb" { + security_group_id = aws_security_group.rds.id + referenced_security_group_id = aws_security_group.eb.id + from_port = 1433 + to_port = 1433 + ip_protocol = "tcp" + description = "EB instances to SQL Server" +} + +resource "aws_vpc_security_group_egress_rule" "rds_all" { + security_group_id = aws_security_group.rds.id + cidr_ipv4 = "0.0.0.0/0" + ip_protocol = "-1" +} diff --git a/terraform/terraform.tfvars.example b/terraform/terraform.tfvars.example new file mode 100644 index 0000000..b499714 --- /dev/null +++ b/terraform/terraform.tfvars.example @@ -0,0 +1,25 @@ +aws_region = "us-east-1" +aws_account_id = "396287094661" +environment = "tf-poc" +domain_name = "tf-poc.api.dev.seahaven.com" +hosted_zone_id = "Z07671212N75U4YLPWZR8" +hosted_zone_name = "dev.seahaven.com" +github_repo = "Sea-Haven-Industries/shoc-backend" +github_environment = "tf-poc" +github_deploy_role_name = "githubdeploy-shoc-backend-tf-poc" +eb_application_name = "shoc-backend-tf-poc" +eb_environment_name = "shoc-backend-tf-poc" +eb_ec2_role_name = "shoc-backend-tf-poc-ec2" +eb_service_role_name = "shoc-eb-service-role" +eb_instance_type = "t3.small" +rds_identifier = "shoc-backend-tf-poc" +rds_instance_class = "db.t3.small" +rds_allocated_storage = 20 +rds_master_username = "shoc_admin" +database_name = "shoc_tf_poc" + +# Copied from live shoc-backend-dev. Same public subnets for instances and ALB. +vpc_id = "vpc-0d16336143f3da25e" +private_subnet_ids = ["subnet-09eaf2bfa468d206f", "subnet-02946ccd6735742e9", "subnet-0bedaa6e0c750be4f"] +public_subnet_ids = ["subnet-09eaf2bfa468d206f", "subnet-02946ccd6735742e9", "subnet-0bedaa6e0c750be4f"] +associate_public_ip = true diff --git a/terraform/variables.tf b/terraform/variables.tf new file mode 100644 index 0000000..71deaf3 --- /dev/null +++ b/terraform/variables.tf @@ -0,0 +1,195 @@ +variable "aws_region" { + type = string + description = "AWS region for the API, RDS, ACM, and Elastic Beanstalk." + default = "us-east-1" +} + +variable "aws_account_id" { + type = string + description = "seahaven-external-dev. Apply refuses any other account." + default = "396287094661" + + validation { + condition = var.aws_account_id == "396287094661" + error_message = "This stack is only for seahaven-external-dev (396287094661)." + } +} + +variable "environment" { + type = string + description = "Logical environment. POC apply is tf-poc only. dev/staging/prod are accepted so promotion does not rewrite the module." + default = "tf-poc" + + validation { + condition = contains(["tf-poc", "dev", "staging", "prod"], var.environment) + error_message = "environment must be tf-poc, dev, staging, or prod." + } +} + +variable "domain_name" { + type = string + description = "Public API hostname." + default = "tf-poc.api.dev.seahaven.com" + + validation { + condition = var.environment != "tf-poc" || var.domain_name == "tf-poc.api.dev.seahaven.com" + error_message = "tf-poc hostname must be tf-poc.api.dev.seahaven.com." + } + + validation { + condition = var.environment != "dev" || var.domain_name == "api.dev.seahaven.com" + error_message = "dev hostname must be api.dev.seahaven.com." + } + + validation { + condition = var.domain_name != "api.tf-poc.dev.seahaven.com" + error_message = "Refuse api.tf-poc.dev.seahaven.com; use tf-poc.api.dev.seahaven.com." + } +} + +variable "hosted_zone_id" { + type = string + description = "Route 53 zone for the hostname + ACM DNS validation." + default = "Z07671212N75U4YLPWZR8" +} + +variable "hosted_zone_name" { + type = string + description = "Zone name; used only for documentation and FQDN checks." + default = "dev.seahaven.com" +} + +variable "github_repo" { + type = string + description = "GitHub owner/name for the deploy OIDC trust" + default = "Sea-Haven-Industries/shoc-backend" +} + +variable "github_environment" { + type = string + description = "GitHub Actions environment name used in the OIDC sub" + default = "tf-poc" +} + +variable "github_deploy_role_name" { + type = string + description = "IAM role name at path /tf-managed/" + default = "githubdeploy-shoc-backend-tf-poc" +} + +variable "eb_application_name" { + type = string + description = "Elastic Beanstalk application name. POC uses a separate app from live shoc-backend." + default = "shoc-backend-tf-poc" + + validation { + condition = var.environment != "tf-poc" || var.eb_application_name == "shoc-backend-tf-poc" + error_message = "tf-poc Elastic Beanstalk application must be shoc-backend-tf-poc." + } + + validation { + condition = var.environment != "dev" || var.eb_application_name == "shoc-backend" + error_message = "dev Elastic Beanstalk application must be shoc-backend." + } +} + +variable "eb_environment_name" { + type = string + description = "Elastic Beanstalk environment name." + default = "shoc-backend-tf-poc" + + validation { + condition = var.environment != "tf-poc" || var.eb_environment_name == "shoc-backend-tf-poc" + error_message = "tf-poc Elastic Beanstalk environment must be shoc-backend-tf-poc." + } + + validation { + condition = var.environment != "dev" || var.eb_environment_name == "shoc-backend-dev" + error_message = "dev Elastic Beanstalk environment must be shoc-backend-dev." + } +} + +variable "eb_ec2_role_name" { + type = string + description = "Instance role / instance-profile name at path /tf-managed/" + default = "shoc-backend-tf-poc-ec2" +} + +variable "eb_service_role_name" { + type = string + description = "Existing Elastic Beanstalk service role (data-sourced, not created)" + default = "shoc-eb-service-role" +} + +variable "eb_instance_type" { + type = string + description = "EC2 instance type for the POC environment" + default = "t3.small" +} + +variable "vpc_id" { + type = string + description = "Existing VPC that hosts live Elastic Beanstalk. Required at apply; discover before first apply." + default = "" +} + +variable "private_subnet_ids" { + type = list(string) + description = "Private subnets for RDS and EB instances (at least two AZs)." + default = [] +} + +variable "public_subnet_ids" { + type = list(string) + description = "Public subnets for the EB application load balancer (at least two AZs)." + default = [] +} + +variable "associate_public_ip" { + type = bool + description = "Associate a public IP on EB instances. Live shoc-backend-dev uses true on public subnets." + default = true +} + +variable "rds_identifier" { + type = string + description = "RDS instance identifier" + default = "shoc-backend-tf-poc" +} + +variable "rds_instance_class" { + type = string + description = "RDS SQL Server Express instance class" + default = "db.t3.small" +} + +variable "rds_allocated_storage" { + type = number + description = "RDS allocated storage in GiB. SQL Server minimum is 20. Express database size remains 10 GiB." + default = 20 +} + +variable "rds_master_username" { + type = string + description = "RDS master username. Cannot be sa/admin/root." + default = "shoc_admin" +} + +variable "database_name" { + type = string + description = "SQL Server catalog the API uses. Create this database once after RDS is available (RDS Express does not accept db_name)." + default = "shoc_tf_poc" +} + +variable "sendgrid_api_key" { + type = string + description = "SendGrid API key. Set as a sensitive HCP workspace variable before smoke that sends mail. Empty is allowed for first apply." + default = "" + sensitive = true +} + +variable "execution_boundary_arn" { + type = string + description = "SCP-required permissions boundary on CreateRole" + default = "arn:aws:iam::396287094661:policy/external-dev-execution-boundary" +} diff --git a/terraform/versions.tf b/terraform/versions.tf new file mode 100644 index 0000000..27c943f --- /dev/null +++ b/terraform/versions.tf @@ -0,0 +1,22 @@ +terraform { + required_version = ">= 1.7.0" + + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 6.57" + } + random = { + source = "hashicorp/random" + version = "~> 3.6" + } + } + + cloud { + organization = "seahaven" + + workspaces { + name = "shoc-backend-tf-poc" + } + } +}