commit 6136cde9bbc81aa494013e7bee651dceff16686a Author: Greg Hendrickson Date: Sun Feb 1 20:06:28 2026 +0000 feat: Terraform Foundation - AWS Landing Zone Enterprise-grade multi-tenant AWS cloud foundation. Modules: - GitHub OIDC for keyless CI/CD authentication - IAM account settings and security baseline - AWS Config Rules for compliance - ABAC (Attribute-Based Access Control) - SCPs (Service Control Policies) Features: - Multi-account architecture - Cost optimization patterns - Security best practices - Comprehensive documentation Tech: Terraform, AWS Organizations, IAM Identity Center diff --git a/.checkov.yml b/.checkov.yml new file mode 100644 index 0000000..3baa324 --- /dev/null +++ b/.checkov.yml @@ -0,0 +1,36 @@ +# Checkov Configuration +# Security and compliance scanning for Terraform +# https://www.checkov.io/ + +# Framework to scan +framework: + - terraform + +# Directories to scan +directory: + - terraform/modules + - live + +# Skip specific checks with justification +skip-check: + # These are intentionally broad for template/example modules + - CKV_AWS_111 # IAM policy allows * in resource - needed for flexible templates + - CKV_AWS_355 # IAM policy document allows * - same reason + - CKV2_AWS_62 # S3 event notifications - not always needed + +# Soft fail for CI (don't block, just warn) +soft-fail: false + +# Output format +output: + - cli + - junitxml + +# Compact output +compact: true + +# Download external modules +download-external-modules: false + +# Evaluate variables +evaluate-variables: true diff --git a/.github/workflows/terraform.yml b/.github/workflows/terraform.yml new file mode 100644 index 0000000..34a1d61 --- /dev/null +++ b/.github/workflows/terraform.yml @@ -0,0 +1,219 @@ +name: Terraform + +on: + push: + branches: [main] + pull_request: + branches: [main] + +env: + AWS_REGION: us-east-1 + TF_VERSION: 1.7.0 + TG_VERSION: 0.55.0 + +permissions: + id-token: write + contents: read + pull-requests: write + security-events: write # For SARIF upload + +jobs: + # Security scanning + security: + name: Security Scan + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Run tfsec + uses: aquasecurity/tfsec-action@v1.0.3 + with: + soft_fail: true + sarif_file: tfsec.sarif + + - name: Run Checkov + uses: bridgecrewio/checkov-action@v12 + with: + directory: terraform/modules + framework: terraform + output_format: sarif + output_file_path: checkov.sarif + soft_fail: true + config_file: .checkov.yml + + - name: Run Trivy + uses: aquasecurity/trivy-action@master + with: + scan-type: 'config' + scan-ref: 'terraform/modules' + format: 'sarif' + output: 'trivy.sarif' + exit-code: '0' + severity: 'CRITICAL,HIGH' + + - name: Upload SARIF results + uses: github/codeql-action/upload-sarif@v3 + if: always() + with: + sarif_file: '.' + category: security-scan + + # Linting + lint: + name: Lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Terraform + uses: hashicorp/setup-terraform@v3 + with: + terraform_version: ${{ env.TF_VERSION }} + + - name: Terraform Format Check + run: terraform fmt -check -recursive terraform/modules + + - name: Setup TFLint + uses: terraform-linters/setup-tflint@v4 + with: + tflint_version: v0.50.0 + + - name: Init TFLint + run: tflint --init --config=.tflint.hcl + + - name: Run TFLint + run: | + for module in terraform/modules/*/; do + echo "Linting $module..." + tflint --config=.tflint.hcl --chdir="$module" || true + done + + # Validate on PRs + validate: + name: Validate + runs-on: ubuntu-latest + if: github.event_name == 'pull_request' + needs: [security, lint] + + steps: + - uses: actions/checkout@v4 + + - name: Configure AWS Credentials (Read-Only) + uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: ${{ vars.AWS_ROLE_VALIDATE }} + aws-region: ${{ env.AWS_REGION }} + role-session-name: tf-validate-${{ github.run_id }} + + - name: Setup Terraform + uses: hashicorp/setup-terraform@v3 + with: + terraform_version: ${{ env.TF_VERSION }} + + - name: Setup Terragrunt + run: | + wget -q https://github.com/gruntwork-io/terragrunt/releases/download/v${{ env.TG_VERSION }}/terragrunt_linux_amd64 + chmod +x terragrunt_linux_amd64 + sudo mv terragrunt_linux_amd64 /usr/local/bin/terragrunt + + - name: Validate Modules + run: | + for module in terraform/modules/*/; do + echo "Validating $module..." + cd "$module" + terraform init -backend=false + terraform validate + cd - + done + + - name: Terragrunt Plan + run: | + cd live + terragrunt run-all plan --terragrunt-non-interactive -out=tfplan + continue-on-error: true + + - name: Post Plan to PR + uses: actions/github-script@v7 + if: github.event_name == 'pull_request' + with: + script: | + const output = `#### Terraform Validation ✅ + *Pushed by: @${{ github.actor }}, Action: \`${{ github.event_name }}\`*`; + github.rest.issues.createComment({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + body: output + }) + + # Deploy on merge to main + deploy: + name: Deploy + runs-on: ubuntu-latest + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + environment: production + needs: [security, lint] + + steps: + - uses: actions/checkout@v4 + + - name: Configure AWS Credentials (Deploy) + uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: ${{ vars.AWS_ROLE_DEPLOY }} + aws-region: ${{ env.AWS_REGION }} + role-session-name: tf-deploy-${{ github.run_id }} + + - name: Setup Terraform + uses: hashicorp/setup-terraform@v3 + with: + terraform_version: ${{ env.TF_VERSION }} + + - name: Setup Terragrunt + run: | + wget -q https://github.com/gruntwork-io/terragrunt/releases/download/v${{ env.TG_VERSION }}/terragrunt_linux_amd64 + chmod +x terragrunt_linux_amd64 + sudo mv terragrunt_linux_amd64 /usr/local/bin/terragrunt + + - name: Terragrunt Apply + run: | + cd live + terragrunt run-all apply --terragrunt-non-interactive -auto-approve + + # Module tests + test-modules: + name: Test Modules + runs-on: ubuntu-latest + if: github.event_name == 'pull_request' + + strategy: + matrix: + module: + - github-oidc + - vpc-lite + - iam-role + fail-fast: false + + steps: + - uses: actions/checkout@v4 + + - name: Setup Terraform + uses: hashicorp/setup-terraform@v3 + with: + terraform_version: ${{ env.TF_VERSION }} + + - name: Terraform Init + run: terraform init -backend=false + working-directory: terraform/modules/${{ matrix.module }} + + - name: Terraform Validate + run: terraform validate + working-directory: terraform/modules/${{ matrix.module }} + + - name: Check Documentation + run: | + if [ ! -f README.md ]; then + echo "Missing README.md in ${{ matrix.module }}" + exit 1 + fi + working-directory: terraform/modules/${{ matrix.module }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..18c0da0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,44 @@ +# Terraform +**/.terraform/ +**/.terraform.lock.hcl +*.tfstate +*.tfstate.* +*.tfplan +tfplan +crash.log +crash.*.log +override.tf +override.tf.json +*_override.tf +*_override.tf.json + +# Secrets & credentials +*.tfvars +!*.tfvars.example +backend.hcl +.env +.env.* + +# Local artifacts +lambda.zip +*.zip + +# IDE +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Infracost +.infracost/ + +# OpenCode +.opencode/ + +# MCP +.mcp.json diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..2b34ce2 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,90 @@ +# Pre-commit hooks for Terraform +# Install: pip install pre-commit && pre-commit install +# +# Tools: +# - terraform fmt/validate +# - tflint (with AWS plugin) +# - tfsec (security scanner) +# - checkov (policy-as-code) +# - terraform-docs (auto-generate docs) +# - trivy (vulnerability scanner) + +repos: + # Terraform formatting and validation + - repo: https://github.com/antonbabenko/pre-commit-terraform + rev: v1.86.0 + hooks: + - id: terraform_fmt + - id: terraform_validate + args: + - --hook-config=--retry-once-with-cleanup=true + - id: terraform_tflint + args: + - --args=--config=__GIT_WORKING_DIR__/.tflint.hcl + - id: terraform_docs + args: + - --args=--config=.terraform-docs.yml + - id: terraform_tfsec + args: + - --args=--soft-fail + - --args=--exclude-downloaded-modules + - id: terraform_checkov + args: + - --args=--config-file=__GIT_WORKING_DIR__/.checkov.yml + - --args=--framework=terraform + - --args=--download-external-modules=false + + # Trivy security scanner + - repo: https://github.com/aquasecurity/trivy + rev: v0.48.0 + hooks: + - id: trivy + args: + - --config=.trivy.yaml + - --exit-code=0 # Warn only + files: \.tf$ + + # General file checks + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.5.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + args: ['--unsafe'] + - id: check-json + - id: check-merge-conflict + - id: detect-private-key + - id: no-commit-to-branch + args: ['--branch', 'main'] + stages: [commit] + + # Security scanning for secrets + - repo: https://github.com/Yelp/detect-secrets + rev: v1.4.0 + hooks: + - id: detect-secrets + args: ['--baseline', '.secrets.baseline'] + exclude: '\.terraform/.*|\.terraform\.lock\.hcl' + + # Markdown linting + - repo: https://github.com/igorshubovych/markdownlint-cli + rev: v0.39.0 + hooks: + - id: markdownlint + args: ['--fix', '--disable', 'MD013', 'MD033', 'MD041'] + + # YAML linting + - repo: https://github.com/adrienverge/yamllint + rev: v1.33.0 + hooks: + - id: yamllint + args: ['-c', '.yamllint.yml'] + exclude: '\.terraform/.*' + + # Shell script linting + - repo: https://github.com/shellcheck-py/shellcheck-py + rev: v0.9.0.6 + hooks: + - id: shellcheck + args: ['--severity=warning'] diff --git a/.terraform-docs.yml b/.terraform-docs.yml new file mode 100644 index 0000000..5bbe8ab --- /dev/null +++ b/.terraform-docs.yml @@ -0,0 +1,76 @@ +# terraform-docs configuration +# https://terraform-docs.io/user-guide/configuration/ + +formatter: markdown table + +version: ">= 0.16.0" + +header-from: "" +footer-from: "" + +recursive: + enabled: false + +sections: + hide: [] + show: [] + +content: |- + {{ .Header }} + + ## Requirements + + {{ .Requirements }} + + ## Providers + + {{ .Providers }} + + ## Modules + + {{ .Modules }} + + ## Resources + + {{ .Resources }} + + ## Inputs + + {{ .Inputs }} + + ## Outputs + + {{ .Outputs }} + + {{ .Footer }} + +output: + file: README.md + mode: inject + template: |- + + {{ .Content }} + + +output-values: + enabled: false + from: "" + +sort: + enabled: true + by: required + +settings: + anchor: true + color: true + default: true + description: true + escape: true + hide-empty: false + html: true + indent: 2 + lockfile: true + read-comments: true + required: true + sensitive: true + type: true diff --git a/.tflint.hcl b/.tflint.hcl new file mode 100644 index 0000000..d749c25 --- /dev/null +++ b/.tflint.hcl @@ -0,0 +1,138 @@ +# TFLint Configuration +# Terraform linting with AWS best practices +# https://github.com/terraform-linters/tflint + +config { + module = true + force = false +} + +# AWS Provider Plugin +plugin "aws" { + enabled = true + version = "0.29.0" + source = "github.com/terraform-linters/tflint-ruleset-aws" + + # Deep checking (requires AWS credentials) + deep_check = false +} + +################################################################################ +# Terraform Core Rules +################################################################################ + +# Enforce snake_case naming +rule "terraform_naming_convention" { + enabled = true + format = "snake_case" +} + +# Require descriptions +rule "terraform_documented_variables" { + enabled = true +} + +rule "terraform_documented_outputs" { + enabled = true +} + +# Standard module structure +rule "terraform_standard_module_structure" { + enabled = true +} + +# Deprecated syntax +rule "terraform_deprecated_interpolation" { + enabled = true +} + +rule "terraform_deprecated_index" { + enabled = true +} + +# Comment formatting +rule "terraform_comment_syntax" { + enabled = true +} + +# Require type declarations +rule "terraform_typed_variables" { + enabled = true +} + +# Workspace usage (discouraged with Terragrunt) +rule "terraform_workspace_remote" { + enabled = true +} + +################################################################################ +# AWS Security Rules +################################################################################ + +# Invalid instance types +rule "aws_instance_invalid_type" { + enabled = true +} + +# Invalid AMIs +rule "aws_instance_invalid_ami" { + enabled = true +} + +# Resource tagging +rule "aws_resource_missing_tags" { + enabled = true + tags = ["Name", "Environment", "ManagedBy"] +} + +# IAM Policy best practices +rule "aws_iam_policy_document_gov_friendly_arns" { + enabled = true +} + +rule "aws_iam_policy_too_long_policy" { + enabled = true +} + +# S3 bucket configuration +rule "aws_s3_bucket_invalid_acl" { + enabled = true +} + +# Security group rules +rule "aws_security_group_invalid_protocol" { + enabled = true +} + +# DB instance sizing +rule "aws_db_instance_invalid_type" { + enabled = true +} + +rule "aws_db_instance_invalid_db_subnet_group" { + enabled = true +} + +# ElastiCache +rule "aws_elasticache_cluster_invalid_type" { + enabled = true +} + +# Lambda +rule "aws_lambda_function_invalid_runtime" { + enabled = true +} + +################################################################################ +# Disabled Rules +################################################################################ + +# Too strict for template modules with dynamic configs +rule "terraform_unused_declarations" { + enabled = false +} + +# Allow empty defaults for optional objects +rule "terraform_required_providers" { + enabled = false +} diff --git a/.trivy.yaml b/.trivy.yaml new file mode 100644 index 0000000..12564a0 --- /dev/null +++ b/.trivy.yaml @@ -0,0 +1,37 @@ +# Trivy Configuration +# Security and misconfiguration scanning +# https://aquasecurity.github.io/trivy/ + +# Scan severity levels +severity: + - CRITICAL + - HIGH + - MEDIUM + +# Exit code (0 = warn only, 1 = fail on findings) +exit-code: 0 + +# Output format +format: table + +# Ignore unfixed vulnerabilities +ignore-unfixed: true + +# Skip directories +skip-dirs: + - .terraform + - .git + - node_modules + +# Custom policy paths +# policy: +# - ./policies + +# Misconfiguration scanning +misconfiguration: + # Terraform-specific checks + terraform: + exclude-downloaded-modules: true + +# Ignore specific checks +ignorefile: .trivyignore diff --git a/.yamllint.yml b/.yamllint.yml new file mode 100644 index 0000000..107d6eb --- /dev/null +++ b/.yamllint.yml @@ -0,0 +1,41 @@ +# yamllint configuration +# https://yamllint.readthedocs.io/ + +extends: default + +rules: + # Allow long lines (common in Terraform/CI configs) + line-length: + max: 200 + level: warning + + # Allow inline comments + comments: + min-spaces-from-content: 1 + + # Relaxed indentation for readability + indentation: + spaces: 2 + indent-sequences: consistent + + # Allow document start without --- + document-start: disable + + # Truthy values (allow yes/no/on/off) + truthy: + allowed-values: ['true', 'false', 'yes', 'no', 'on', 'off'] + + # Braces spacing + braces: + min-spaces-inside: 0 + max-spaces-inside: 1 + + # Brackets spacing + brackets: + min-spaces-inside: 0 + max-spaces-inside: 1 + +ignore: | + .terraform/ + **/.terraform/ + node_modules/ diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..afd2a9d --- /dev/null +++ b/Makefile @@ -0,0 +1,149 @@ +# Terraform Foundation - Makefile +# Common commands for managing the infrastructure + +.PHONY: help init fmt validate plan apply destroy docs clean + +# Default target +help: + @echo "Terraform Foundation - Available Commands" + @echo "" + @echo " make init Initialize all Terraform layers" + @echo " make fmt Format all Terraform files" + @echo " make validate Validate all configurations" + @echo " make plan Plan all layers (dry run)" + @echo " make apply Apply all layers" + @echo " make docs Generate documentation" + @echo " make clean Clean up local artifacts" + @echo "" + @echo "Layer-specific commands:" + @echo " make plan-bootstrap" + @echo " make plan-org" + @echo " make plan-network" + @echo " make plan-platform" + @echo "" + @echo "Tenant commands:" + @echo " make new-tenant NAME=acme" + @echo " make plan-tenant NAME=acme" + @echo " make apply-tenant NAME=acme" + +# Configuration +TF_DIR := terraform +STATE_BUCKET ?= $(shell cat $(TF_DIR)/00-bootstrap/backend.hcl 2>/dev/null | grep bucket | cut -d'"' -f2) +REGION ?= us-east-1 + +# Initialize all layers +init: + @echo "Initializing Terraform layers..." + @cd $(TF_DIR)/00-bootstrap && terraform init + @if [ -n "$(STATE_BUCKET)" ]; then \ + for dir in 01-organization 02-network 03-platform; do \ + if [ -f "$(TF_DIR)/$$dir/main.tf" ]; then \ + echo "Initializing $$dir..."; \ + cd $(TF_DIR)/$$dir && terraform init -backend-config=../00-bootstrap/backend.hcl; \ + cd - > /dev/null; \ + fi; \ + done; \ + else \ + echo "Note: Run 'make apply-bootstrap' first to configure remote state"; \ + fi + +# Format all Terraform files +fmt: + @echo "Formatting Terraform files..." + @terraform fmt -recursive $(TF_DIR) + +# Validate all configurations +validate: fmt + @echo "Validating Terraform configurations..." + @for dir in $(TF_DIR)/00-bootstrap $(TF_DIR)/01-organization $(TF_DIR)/02-network $(TF_DIR)/03-platform; do \ + if [ -f "$$dir/main.tf" ]; then \ + echo "Validating $$dir..."; \ + cd $$dir && terraform init -backend=false -input=false >/dev/null 2>&1 && terraform validate && cd - > /dev/null; \ + fi; \ + done + @echo "✓ All configurations valid" + +# Plan all layers +plan: + @./scripts/apply-all.sh plan + +# Apply all layers +apply: + @./scripts/apply-all.sh apply + +# Destroy (use with caution!) +destroy: + @echo "⚠️ This will destroy ALL infrastructure!" + @read -p "Type 'destroy' to confirm: " confirm && [ "$$confirm" = "destroy" ] + @./scripts/apply-all.sh destroy + +# Layer-specific targets +plan-bootstrap: + @cd $(TF_DIR)/00-bootstrap && terraform plan + +apply-bootstrap: + @cd $(TF_DIR)/00-bootstrap && terraform apply + +plan-org: + @cd $(TF_DIR)/01-organization && terraform plan + +apply-org: + @cd $(TF_DIR)/01-organization && terraform apply + +plan-network: + @cd $(TF_DIR)/02-network && terraform plan -var="state_bucket=$(STATE_BUCKET)" + +apply-network: + @cd $(TF_DIR)/02-network && terraform apply -var="state_bucket=$(STATE_BUCKET)" + +plan-platform: + @cd $(TF_DIR)/03-platform && terraform plan -var="state_bucket=$(STATE_BUCKET)" -var="project_name=$(PROJECT_NAME)" + +apply-platform: + @cd $(TF_DIR)/03-platform && terraform apply -var="state_bucket=$(STATE_BUCKET)" -var="project_name=$(PROJECT_NAME)" + +# Tenant commands +new-tenant: + @if [ -z "$(NAME)" ]; then echo "Usage: make new-tenant NAME="; exit 1; fi + @./scripts/new-tenant.sh $(NAME) + +plan-tenant: + @if [ -z "$(NAME)" ]; then echo "Usage: make plan-tenant NAME="; exit 1; fi + @cd $(TF_DIR)/04-tenants/$(NAME) && terraform plan -var="state_bucket=$(STATE_BUCKET)" + +apply-tenant: + @if [ -z "$(NAME)" ]; then echo "Usage: make apply-tenant NAME="; exit 1; fi + @cd $(TF_DIR)/04-tenants/$(NAME) && terraform apply -var="state_bucket=$(STATE_BUCKET)" + +# Generate documentation +docs: + @echo "Generating documentation..." + @which terraform-docs > /dev/null 2>&1 || (echo "Install terraform-docs: brew install terraform-docs" && exit 1) + @for dir in $(TF_DIR)/modules/*; do \ + if [ -d "$$dir" ]; then \ + terraform-docs markdown table $$dir > $$dir/README.md 2>/dev/null || true; \ + fi; \ + done + @echo "✓ Documentation generated" + +# Clean up local artifacts +clean: + @echo "Cleaning up..." + @find $(TF_DIR) -name ".terraform" -type d -exec rm -rf {} + 2>/dev/null || true + @find $(TF_DIR) -name "*.tfstate*" -type f -delete 2>/dev/null || true + @find $(TF_DIR) -name ".terraform.lock.hcl" -type f -delete 2>/dev/null || true + @find $(TF_DIR) -name "tfplan" -type f -delete 2>/dev/null || true + @find $(TF_DIR) -name "lambda.zip" -type f -delete 2>/dev/null || true + @echo "✓ Cleanup complete" + +# Security scan +security: + @echo "Running security scan..." + @which tfsec > /dev/null 2>&1 || (echo "Install tfsec: brew install tfsec" && exit 1) + @tfsec $(TF_DIR) + +# Cost estimate (requires Infracost) +cost: + @echo "Estimating costs..." + @which infracost > /dev/null 2>&1 || (echo "Install infracost: brew install infracost" && exit 1) + @infracost breakdown --path $(TF_DIR) diff --git a/README.md b/README.md new file mode 100644 index 0000000..3b83128 --- /dev/null +++ b/README.md @@ -0,0 +1,592 @@ +# Terraform Foundation + +![Terraform](https://img.shields.io/badge/Terraform-1.5+-7B42BC?style=flat&logo=terraform) +![AWS](https://img.shields.io/badge/Cloud-AWS-FF9900?style=flat&logo=amazon-aws) +![License](https://img.shields.io/badge/License-MIT-blue) + +Enterprise-grade cloud foundation with multi-tenancy, designed to scale from startup to enterprise. + +## Features + +- 🏢 **Multi-tenancy** - Logical tenant isolation via tags & ABAC +- 💰 **Cost optimized** - Single shared VPC, one NAT Gateway +- 🔒 **Security** - SCPs, tag enforcement, tenant-scoped IAM +- 📊 **Billing** - Per-tenant and per-app budget alerts +- 🎚️ **Flexible** - Single-account or multi-account deployment +- 🚀 **CI/CD Ready** - GitHub Actions workflow included +- 📦 **Workload Templates** - ECS, Lambda, RDS ready to deploy + +## Deployment Modes + +| Mode | Accounts | Best For | Cost | +|------|----------|----------|------| +| **single-account** | 1 | Startups, POCs, small teams | $ | +| **multi-account** | 1 per env (prod/staging/dev) | Growing companies, compliance | $$ | + +Both modes use the same tenant isolation pattern (tags + ABAC + security groups). + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ Shared VPC │ +│ │ +│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ +│ │ Tenant A │ │ Tenant B │ │ Tenant C │ │ +│ │ SG: A-* │ │ SG: B-* │ │ SG: C-* │ │ +│ └─────────────┘ └─────────────┘ └─────────────┘ │ +│ │ +│ Isolation: Security Groups + Tags (ABAC) + IAM │ +│ Cost: Single NAT Gateway (~$32/mo vs $288 for 3 separate VPCs) │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +## Quick Start + +### Prerequisites + +- Terraform >= 1.5 +- AWS CLI configured with appropriate permissions +- Make (optional, for convenience commands) + +### Single-Account Mode (Fastest) + +```bash +# 1. Bootstrap +cd terraform/00-bootstrap +terraform init +terraform apply -var="project_name=myproject" -var="deployment_mode=single-account" + +# 2. Network (skip 01-organization in single-account mode) +cd ../02-network +terraform init -backend-config=../00-bootstrap/backend.hcl +terraform apply -var="state_bucket=myproject-terraform-state" + +# 3. Platform (ECR, CI/CD) +cd ../03-platform +terraform init -backend-config=../00-bootstrap/backend.hcl +terraform apply -var="state_bucket=myproject-terraform-state" -var="project_name=myproject" + +# 4. Add a tenant +./scripts/new-tenant.sh acme +cd terraform/04-tenants/acme +# Edit main.tf (apps, budgets, emails) +terraform init -backend-config=../../00-bootstrap/backend.hcl +terraform apply -var="state_bucket=myproject-terraform-state" + +# 5. Deploy a workload +./scripts/new-workload.sh ecs acme api +cd terraform/05-workloads/acme-api +# Edit main.tf (container image, ports, scaling) +terraform init -backend-config=../../00-bootstrap/backend.hcl +terraform apply -var="state_bucket=myproject-terraform-state" +``` + +### Multi-Account Mode (Enterprise) + +```bash +# 1. Bootstrap +cd terraform/00-bootstrap +terraform init +terraform apply -var="project_name=myorg" -var="deployment_mode=multi-account" + +# 2. Organization (creates AWS Org, OUs, core accounts) +cd ../01-organization +terraform init -backend-config=../00-bootstrap/backend.hcl +terraform apply + +# 3. Network (VPC in dedicated network account) +cd ../02-network +terraform init -backend-config=../00-bootstrap/backend.hcl +terraform apply -var="state_bucket=myorg-terraform-state" -var="deployment_mode=multi-account" + +# 4. Platform & tenants as above +``` + +### Using Make + +```bash +make help # Show all commands +make init # Initialize all layers +make plan # Plan all layers +make apply # Apply all layers +make new-tenant NAME=acme +make plan-tenant NAME=acme +``` + +## Layered Structure + +Apply in order — each layer depends on the previous: + +``` +terraform/ +├── 00-bootstrap/ # State bucket, locks, KMS (FIRST) +├── 01-organization/ # AWS Org, OUs, SCPs (multi-account only) +├── 02-network/ # Shared VPC, NAT, subnets +├── 03-platform/ # Shared services: ECR, CodeBuild +├── 04-tenants/ # Per-tenant: SGs, IAM, budgets +│ ├── _template/ # Copy for new tenants +│ ├── acme/ +│ └── globex/ +├── 05-workloads/ # Actual resources +│ ├── _template/ +│ │ ├── ecs-service/ +│ │ ├── eks-cluster/ +│ │ ├── elasticache-redis/ +│ │ ├── lambda-function/ +│ │ ├── rds-database/ +│ │ ├── sqs-queue/ +│ │ └── static-site/ +│ ├── acme-api/ +│ └── acme-db/ +└── modules/ # Reusable modules + ├── backup-plan/ # AWS Backup automation + ├── vpc-endpoints/ # PrivateLink endpoints + └── ... +``` + +## Tenant Isolation + +### Security Groups + +Each tenant gets isolated SGs that **only allow intra-tenant traffic**: + +``` +acme-prod-base-sg → Self-referencing (acme can talk to acme) +acme-prod-web-sg → 443/80 from internet +acme-prod-app-sg → 8080 from acme-base only +acme-prod-db-sg → 5432 from acme-base only + +❌ globex-* cannot reach acme-* (no SG rules allow it) +``` + +### ABAC (Attribute-Based Access Control) + +IAM roles are scoped to tenant by tag: + +```hcl +# acme-admin can ONLY touch resources tagged Tenant=acme +Condition = { + StringEquals = { + "aws:ResourceTag/Tenant" = "acme" + } +} + +# Must tag new resources correctly +Condition = { + StringEquals = { + "aws:RequestTag/Tenant" = "acme" + } +} +``` + +### Budgets + +- **Tenant budget**: Total spend for all apps +- **App budgets**: Per-app limits +- **Alerts**: 50%, 80%, 100% thresholds → email + +## Workload Templates + +### ECS Fargate Service + +Full container orchestration with: +- ECS Cluster with Fargate/Fargate Spot +- Application Load Balancer with access logging +- Auto-scaling (CPU/Memory based) +- CloudWatch logging + +```bash +./scripts/new-workload.sh ecs +``` + +### EKS Kubernetes Cluster + +Production-ready Kubernetes with: +- EKS managed node groups (On-Demand & Spot) +- IRSA (IAM Roles for Service Accounts) +- Core addons (VPC CNI, CoreDNS, kube-proxy, EBS CSI) +- IMDSv2 enforced, encrypted EBS volumes +- Cluster Autoscaler & LB Controller ready + +```bash +./scripts/new-workload.sh eks +``` + +### Lambda Function + +Serverless functions with: +- API Gateway HTTP API (optional) +- VPC access for database connectivity +- EventBridge scheduled execution +- X-Ray tracing + +```bash +./scripts/new-workload.sh lambda +``` + +### RDS Database + +Managed databases with: +- PostgreSQL, MySQL, or Aurora +- KMS encryption, IAM authentication +- Secrets Manager for credentials +- Enhanced monitoring, Performance Insights + +```bash +./scripts/new-workload.sh rds +``` + +### ElastiCache Redis + +In-memory caching with: +- Redis 7.x replication group +- Encryption at rest and in transit +- Automatic failover (Multi-AZ) +- Auth token in Secrets Manager + +```bash +./scripts/new-workload.sh redis +``` + +### SQS Queue + +Message queuing with: +- Main queue + dead letter queue +- KMS encryption +- CloudWatch alarms (depth, age, DLQ) +- FIFO support optional + +```bash +./scripts/new-workload.sh sqs +``` + +### DynamoDB Table + +NoSQL database with: +- On-demand or provisioned capacity +- KMS encryption, point-in-time recovery +- GSI/LSI support, TTL +- Auto-scaling (provisioned mode) + +```bash +./scripts/new-workload.sh dynamodb +``` + +### EventBridge Event Bus + +Event-driven architecture with: +- Custom event bus for tenant isolation +- Event rules with pattern matching +- Dead letter queue, event archiving +- Schema discovery + +```bash +./scripts/new-workload.sh eventbus +``` + +### Step Functions Workflow + +Serverless orchestration with: +- Standard or Express workflows +- IAM permissions per service +- CloudWatch logging, X-Ray tracing +- API Gateway or EventBridge triggers + +```bash +./scripts/new-workload.sh stepfn +``` + +### Static Site (S3 + CloudFront) + +CDN-backed static hosting with: +- S3 bucket (private, OAC access) +- CloudFront with HTTPS +- Security headers (CSP, HSTS, etc.) +- Optional custom domain + ACM + +```bash +./scripts/new-workload.sh static +``` + +### ECR Repository + +Container registry with: +- Lifecycle policies (auto-cleanup old images) +- Cross-account pull/push access +- Multi-region replication +- Image scanning on push + +```bash +./scripts/new-workload.sh ecr +``` + +### SNS Topic + +Pub/sub messaging with: +- Multiple subscription types (Lambda, SQS, HTTP, Email) +- Message filtering policies +- Dead letter queue for failed deliveries +- FIFO topics for ordered delivery + +```bash +./scripts/new-workload.sh sns +``` + +### SSM Parameters + +Configuration store with: +- Hierarchical parameter paths +- SecureString for secrets (KMS encrypted) +- Free tier (cheaper than Secrets Manager) +- IAM policies for read/write access + +```bash +./scripts/new-workload.sh params +``` + +### EventBridge Rules + +Event-driven automation with: +- Scheduled rules (cron/rate expressions) +- Event pattern matching (AWS service events) +- Input transformations +- Multiple targets (Lambda, SQS, SNS, Step Functions) + +```bash +./scripts/new-workload.sh events +``` + +### Cognito User Pool + +Authentication with: +- User signup/signin +- Social identity providers +- MFA (TOTP, SMS) +- Custom UI branding +- App clients for web/mobile + +```bash +./scripts/new-workload.sh cognito +``` + +### SES Email + +Transactional email with: +- Domain identity verification +- DKIM/SPF/DMARC +- Email templates +- Reputation monitoring +- Bounce/complaint handling + +```bash +./scripts/new-workload.sh ses +``` + +### API Gateway + +REST API with: +- Lambda integration +- Request validation +- Usage plans and API keys +- Custom domain support +- CloudWatch logging + +```bash +./scripts/new-workload.sh apigw +``` + +## Platform Services (03-platform) + +The platform layer provides shared infrastructure: + +- **ECR Repositories**: Container registry with lifecycle policies +- **CodeBuild**: Shared build project with VPC access +- **S3 Artifacts**: CI/CD artifact storage with lifecycle rules +- **SSM Parameters**: Centralized configuration store + +## Cost Savings + +| Setup | NAT Gateways | Est. Monthly | +|-------|--------------|--------------| +| VPC per tenant (3 tenants, 3 AZ) | 9 | ~$288 | +| **Shared VPC (1 NAT)** | 1 | ~$32 | +| **Savings** | | **~$256/mo** | + +## Scripts + +```bash +# Create new tenant +./scripts/new-tenant.sh + +# Create new workload +./scripts/new-workload.sh + +# Apply all layers in order +./scripts/apply-all.sh plan # Preview +./scripts/apply-all.sh apply # Deploy +``` + +## CI/CD + +GitHub Actions workflow included (`.github/workflows/terraform.yml`): + +- **On PR**: Format check, validate, security scan, plan (comments on PR) +- **On merge**: Auto-apply (requires `production` environment approval) + +Setup: +1. Create an IAM role for GitHub OIDC +2. Add `AWS_ROLE_ARN` to repository secrets +3. Create `production` environment with required reviewers + +## Requirements + +- Terraform >= 1.5 +- AWS CLI configured +- Sufficient IAM permissions (Organizations, IAM, EC2, RDS, etc.) + +### Optional Tools + +- [tfsec](https://github.com/aquasecurity/tfsec) - Security scanning +- [terraform-docs](https://github.com/terraform-docs/terraform-docs) - Documentation generation +- [infracost](https://www.infracost.io/) - Cost estimation + +## Security Controls + +Built-in security controls (see [docs/SECURITY.md](docs/SECURITY.md)): + +| Control | Implementation | +|---------|----------------| +| **Encryption at rest** | KMS for RDS, EBS, S3, SQS, ElastiCache | +| **Encryption in transit** | TLS enforced on all services | +| **Network isolation** | VPC Flow Logs, private subnets, SG-based tenant isolation | +| **Access logging** | ALB, CloudFront, S3, VPC flow logs → centralized bucket | +| **IMDSv2** | Enforced on all EC2/EKS nodes via SCP + launch template | +| **Tag enforcement** | SCP requires Tenant + Environment tags | +| **Audit protection** | SCP prevents disabling CloudTrail, GuardDuty, Config | + +## Reusable Modules + +| Module | Purpose | +|--------|---------| +| **alerting** | SNS topics (critical/warning/info), Slack/PagerDuty integration | +| **backup-plan** | AWS Backup with daily/weekly/monthly, cross-region DR | +| **budget-alerts** | Cost budgets with anomaly detection | +| **cloudtrail** | Audit logging with S3, CloudWatch, KMS | +| **cloudwatch-dashboard** | Pre-built metric dashboards | +| **github-oidc** | Secure CI/CD without long-lived credentials | +| **iam-role** | Service, cross-account, and OIDC roles | +| **kms-key** | Customer-managed encryption keys | +| **route53-zone** | Hosted zones with health checks | +| **security-baseline** | GuardDuty, Security Hub, AWS Config, IAM Access Analyzer | +| **vpc-endpoints** | Gateway (S3, DynamoDB) + Interface endpoints | +| **vpc-lite** | Cost-optimized VPC ($0-$32/mo NAT options) | +| **waf-alb** | AWS WAF with managed rules, rate limiting, geo-blocking | + +## Terragrunt Support + +For DRY multi-environment configuration: + +```bash +live/ +├── terragrunt.hcl # Root config +├── prod/ +│ ├── env.hcl # Environment variables +│ └── network/ +│ └── terragrunt.hcl +├── staging/ +│ └── env.hcl +└── dev/ + └── env.hcl +``` + +Copy `terragrunt.hcl` to your `live/` directory and customize `env.hcl` per environment. + +## Documentation + +- [Security Architecture](docs/SECURITY.md) — Encryption, access control, audit logging +- [Cost Optimization](docs/COST-OPTIMIZATION.md) — Savings strategies, right-sizing guide + +## Roadmap + +- [x] ~~Add 03-platform (shared ECR, CI/CD)~~ +- [x] ~~Add 05-workloads templates (ECS, Lambda, RDS, EKS)~~ +- [x] ~~Security hardening (KMS, VPC Flow Logs, IMDSv2)~~ +- [x] ~~Terragrunt support~~ +- [x] ~~Event-driven templates (EventBridge, Step Functions)~~ +- [x] ~~Security baseline (GuardDuty, Security Hub, Config)~~ +- [x] ~~WAF module for ALB protection~~ +- [x] ~~Alerting module (SNS, Slack, PagerDuty)~~ +- [ ] GCP/Azure modules (future) +- [ ] Service mesh (AWS App Mesh) +- [ ] Prometheus/Grafana on EKS + +## License + +MIT + +## AI-Assisted Development (MCP Servers) + +This repository includes MCP (Model Context Protocol) server configurations for AI-assisted infrastructure development. + +### Available MCP Servers + +| Server | Purpose | +|--------|---------| +| `terraform` | HashiCorp Terraform Registry integration | +| `awslabs.terraform-mcp-server` | AWS-specific Terraform resources | +| `awslabs.aws-documentation-mcp-server` | Real-time AWS documentation | +| `awslabs.cdk-mcp-server` | AWS CDK best practices | +| `awslabs.core-mcp-server` | Core AWS utilities | +| `awslabs.cost-analysis-mcp-server` | Cost Explorer analysis | +| `awslabs.cloudformation-mcp-server` | CloudFormation operations | + +### Setup + +1. **Install prerequisites**: + ```bash + # Node.js (for HashiCorp MCP server) + npm install -g npx + + # Python uv (for AWS Labs servers) + pip install uv + # or + curl -LsSf https://astral.sh/uv/install.sh | sh + ``` + +2. **Configure your MCP client**: + + For **Claude Code**: + ```bash + # Already configured in .mcp.json + claude-code . + ``` + + For **Cursor**: + ```bash + cp .mcp.json .cursor/mcp.json + ``` + + For **VS Code**: + ```bash + cp .mcp.json .vscode/mcp.json + ``` + +### Usage Examples + +With MCP servers enabled, your AI assistant can: + +- **Generate Terraform configurations** using latest provider schemas +- **Look up AWS documentation** for service configurations +- **Apply best practices** from AWS Well-Architected Framework +- **Analyze costs** and suggest optimizations +- **Validate security** against AWS security guidelines + +``` +# Example prompts with MCP +"Create a Terraform module for an ECS service with Fargate" +"What are the latest IAM best practices for GitHub OIDC?" +"Analyze the cost impact of this RDS configuration" +``` + +### Security Note + +MCP servers with AWS credentials (`cost-analysis`, `cloudformation`) are disabled by default. Enable them only when needed and ensure proper IAM permissions. diff --git a/REVIEW.md b/REVIEW.md new file mode 100644 index 0000000..4fe656a --- /dev/null +++ b/REVIEW.md @@ -0,0 +1,69 @@ +# Terraform Foundation - Review Status + +**Last Updated**: 2026-02-01 +**Status**: Partially Implemented + +--- + +## Completed Actions ✅ + +### 1. Removed Empty Modules (10 modules) +- ~~account-baseline~~ +- ~~app-account~~ +- ~~identity-center~~ +- ~~ram-share~~ +- ~~scps~~ +- ~~security-groups~~ +- ~~tenant-baseline~~ +- ~~tenant-budget~~ +- ~~tenant-iam~~ +- ~~tenant-vpc~~ + +### 2. Added README.md to All Modules +All 21 remaining modules now have documentation: +- Usage examples +- Input variables table +- Outputs table + +--- + +## Remaining Work + +### Medium Priority +| Task | Status | +|------|--------| +| Split variables.tf/outputs.tf | Not started | +| Add versions.tf | Not started | +| Add examples/ directories | Not started | +| Add Terraform tests | Not started | + +### Low Priority +| Task | Status | +|------|--------| +| Standardize count→for_each | Not started | +| Add consistent tagging | Not started | +| Generate provider lock files | Not started | + +--- + +## Current Module Status + +| Module | Structure | Docs | Ready | +|--------|-----------|------|-------| +| github-oidc | ✅ | ✅ | ✅ | +| Other modules (20) | 🟡 | ✅ | 🟡 | + +Legend: +- ✅ Complete +- 🟡 Partial (works but not AWS IA compliant) +- ❌ Not ready + +--- + +## Validation Status + +All modules pass `terraform validate` with warnings: +- Deprecation warning: `aws_region.name` (use `.id`) +- Deprecation warning: GuardDuty `datasources` block + +These are cosmetic and do not affect functionality. diff --git a/docs/COST-OPTIMIZATION.md b/docs/COST-OPTIMIZATION.md new file mode 100644 index 0000000..bd61baa --- /dev/null +++ b/docs/COST-OPTIMIZATION.md @@ -0,0 +1,212 @@ +# Cost Optimization Guide + +This document outlines cost-saving strategies implemented in this foundation and recommendations for further optimization. + +## Built-In Cost Savings + +### 1. Shared VPC Architecture + +**Savings: ~$256/month per 3 tenants** + +| Approach | NAT Gateways | Monthly Cost | +|----------|--------------|--------------| +| VPC per tenant (3 tenants, 2 AZ) | 6 | ~$192 | +| **Shared VPC (single NAT)** | 1 | ~$32 | + +The shared VPC with tenant isolation via security groups provides the same logical separation at a fraction of the cost. + +### 2. Single NAT Gateway + +For non-production or cost-sensitive workloads: + +```hcl +# terraform/02-network/main.tf +variable "enable_nat" { + default = true # Set to false to save ~$32/mo (no private subnet egress) +} +``` + +**Alternative**: NAT Instance (~$3/mo for t4g.nano) for dev environments. + +### 3. GP3 Storage (Default) + +All EBS and RDS storage uses GP3: +- 20% cheaper than GP2 +- 3,000 IOPS included (vs 100 IOPS/GB for GP2) +- Configurable IOPS and throughput + +### 4. Fargate Spot (ECS) + +```hcl +# Configured in ECS template +default_capacity_provider_strategy { + base = 1 # 1 On-Demand for availability + weight = 100 + capacity_provider = "FARGATE" # Change to FARGATE_SPOT for 70% savings +} +``` + +**Savings**: Up to 70% on Fargate compute. + +### 5. EKS Spot Instances + +```hcl +# Uncomment in EKS template +node_groups = { + spot = { + instance_types = ["t3.medium", "t3.large", "t3a.medium"] # Diversify! + capacity_type = "SPOT" + # ... + } +} +``` + +**Savings**: Up to 90% on EC2 compute. + +### 6. S3 Intelligent-Tiering + +For logs bucket (already configured): + +```hcl +lifecycle_configuration { + rule { + transition { + days = 90 + storage_class = "GLACIER" + } + expiration { + days = 2555 # 7 years + } + } +} +``` + +### 7. CloudWatch Log Retention + +All log groups configured with retention (default 30 days): + +```hcl +retention_in_days = 30 # Adjust based on compliance needs +``` + +**Cost**: ~$0.03/GB/month for ingestion + storage. + +## Recommendations + +### Compute Right-Sizing + +1. **Start Small**: Use `t3.micro` or `t3.small` for non-prod +2. **Monitor**: Use CloudWatch Container Insights / Compute Optimizer +3. **Scale Down**: Reduce replica counts in dev/staging + +### Reserved Capacity + +| Resource | Savings | Commitment | +|----------|---------|------------| +| EC2 Reserved | 30-72% | 1-3 years | +| RDS Reserved | 30-60% | 1-3 years | +| Savings Plans (Compute) | 20-66% | 1-3 years | +| ElastiCache Reserved | 30-55% | 1-3 years | + +**Recommendation**: After 3 months of stable usage, purchase Compute Savings Plans. + +### Database Optimization + +1. **Aurora Serverless v2**: For variable workloads (scales to 0.5 ACU) +2. **RDS Proxy**: Pool connections, reduce instance size +3. **Read Replicas**: Only for read-heavy workloads +4. **Stop Dev Databases**: Use Lambda to stop/start on schedule + +```hcl +# Example: Smaller dev database +locals { + instance_class = local.env == "prod" ? "db.r6g.large" : "db.t3.micro" +} +``` + +### Networking + +1. **VPC Endpoints**: For S3, ECR, Secrets Manager (~$7/mo each, but saves NAT costs) +2. **PrivateLink**: For high-volume AWS service access +3. **CloudFront**: Cache static content, reduce origin load + +### Monitoring Cost Control + +```hcl +# Reduce metric granularity in non-prod +enhanced_monitoring_interval = local.env == "prod" ? 60 : 0 + +# Disable Performance Insights in dev +performance_insights = local.env != "dev" +``` + +### EKS Specific + +1. **Karpenter**: Better bin-packing than Cluster Autoscaler +2. **Bottlerocket OS**: Smaller footprint, faster boot +3. **Fargate for Batch**: No idle nodes + +## Cost Monitoring + +### AWS Tools + +1. **Cost Explorer**: Built-in, tag-based analysis +2. **Budgets**: Already configured per-tenant +3. **Cost Anomaly Detection**: ML-based alerts + +### Third-Party + +1. **Infracost**: PR-level cost estimation (in Makefile) +2. **Kubecost**: Kubernetes cost allocation +3. **Spot.io**: Spot instance management + +## Environment-Based Defaults + +```hcl +locals { + # Automatically scale down non-prod + instance_class = { + prod = "db.r6g.large" + staging = "db.t3.small" + dev = "db.t3.micro" + }[local.env] + + desired_count = { + prod = 3 + staging = 2 + dev = 1 + }[local.env] + + multi_az = local.env == "prod" +} +``` + +## Estimated Monthly Costs + +### Minimal Setup (Dev/POC) + +| Resource | Spec | Est. Cost | +|----------|------|-----------| +| NAT Gateway | 1 | $32 | +| RDS | db.t3.micro | $13 | +| ECS Fargate | 0.25 vCPU, 0.5GB x 2 | $15 | +| ALB | 1 | $16 | +| S3 + CloudWatch | Minimal | $5 | +| **Total** | | **~$80/mo** | + +### Production (Small) + +| Resource | Spec | Est. Cost | +|----------|------|-----------| +| NAT Gateway | 1 | $32 | +| RDS | db.r6g.large, Multi-AZ | $350 | +| ECS Fargate | 1 vCPU, 2GB x 4 | $120 | +| ALB | 1 | $25 | +| EKS | Control plane | $73 | +| EKS Nodes | 2x t3.medium | $60 | +| S3 + CloudWatch | Moderate | $30 | +| **Total** | | **~$690/mo** | + +### Production (With Savings Plans) + +Same as above with 1-year Compute Savings Plan: **~$480/mo** (30% savings) diff --git a/docs/SECURITY.md b/docs/SECURITY.md new file mode 100644 index 0000000..8d0d875 --- /dev/null +++ b/docs/SECURITY.md @@ -0,0 +1,170 @@ +# Security Architecture + +This document outlines the security controls implemented in this Terraform foundation. These controls align with common compliance frameworks (HIPAA, SOC 2, ISO 27001, HITRUST) without being prescriptive to any specific framework. + +## Encryption + +### At Rest + +| Resource | Encryption | Key Management | +|----------|------------|----------------| +| S3 Buckets | SSE-KMS | Customer-managed KMS keys | +| RDS/Aurora | AES-256 | Customer-managed KMS keys | +| EBS Volumes | AES-256 | Customer-managed KMS keys | +| DynamoDB | AES-256 | Customer-managed KMS keys | +| EKS Secrets | Envelope encryption | Customer-managed KMS keys | +| Secrets Manager | AES-256 | AWS-managed or customer KMS | + +### In Transit + +| Resource | Protocol | Enforcement | +|----------|----------|-------------| +| S3 | TLS 1.2+ | Bucket policy denies non-HTTPS | +| RDS | TLS 1.2+ | `ca_cert_identifier` configured | +| ALB | TLS 1.2+ | HTTPS listeners with modern policy | +| EKS API | TLS 1.2+ | AWS-managed certificates | + +## Access Control + +### Network Isolation + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Shared VPC │ +│ ┌─────────────────┐ ┌─────────────────┐ │ +│ │ Public Subnet │ │ Public Subnet │ ← ALB only │ +│ │ (AZ-a) │ │ (AZ-b) │ │ +│ └────────┬────────┘ └────────┬────────┘ │ +│ │ │ │ +│ ┌────────▼────────┐ ┌────────▼────────┐ │ +│ │ Private Subnet │ │ Private Subnet │ ← Workloads │ +│ │ (AZ-a) │ │ (AZ-b) │ (no public IP) │ +│ └─────────────────┘ └─────────────────┘ │ +│ │ +│ Default SG: DENY ALL (no rules) │ +└─────────────────────────────────────────────────────────────┘ +``` + +### Tenant Isolation + +1. **Security Groups**: Each tenant has isolated SGs; cross-tenant traffic is denied by default +2. **ABAC (Attribute-Based Access Control)**: IAM policies require `Tenant` tag match +3. **Resource Tagging**: All resources tagged with `Tenant`, `App`, `Environment` + +### Identity & Authentication + +| Component | Authentication Method | +|-----------|----------------------| +| AWS Console | IAM + MFA (configure separately) | +| EKS Cluster | OIDC + IAM Roles for Service Accounts | +| RDS | Password + IAM Database Authentication | +| Secrets | Secrets Manager with rotation support | + +## Audit & Logging + +### Log Sources + +| Source | Destination | Retention | +|--------|-------------|-----------| +| VPC Flow Logs | CloudWatch Logs | 90 days | +| ALB Access Logs | S3 (logs bucket) | 7 years | +| RDS Audit Logs | CloudWatch Logs | 30 days | +| EKS Control Plane | CloudWatch Logs | 30 days | +| CloudTrail | S3 (configure separately) | 7 years recommended | + +### Log Protection + +- S3 logs bucket: Versioning enabled, lifecycle to Glacier at 90 days +- CloudWatch Logs: Configurable KMS encryption +- Immutable: S3 Object Lock available (enable for compliance) + +## Compute Security + +### EKS Nodes + +- **IMDSv2 Enforced**: Prevents SSRF-based credential theft +- **Hop Limit = 1**: Containers cannot access node metadata +- **Encrypted EBS**: All node volumes encrypted +- **Private Subnets**: No public IPs on worker nodes + +### ECS/Fargate + +- **No EC2 Management**: Fargate abstracts host security +- **Task IAM Roles**: Least-privilege per service +- **awsvpc Network Mode**: Each task gets own ENI + +### Lambda + +- **VPC Optional**: Deploy in VPC for database access +- **X-Ray Tracing**: Request tracking enabled +- **Reserved Concurrency**: Prevent noisy-neighbor DoS + +## Data Protection + +### Secrets Management + +```hcl +# Secrets Manager with automatic rotation +resource "aws_secretsmanager_secret" "db" { + recovery_window_in_days = 30 # Prod: prevent accidental deletion +} +``` + +### Database Security + +- **No Public Access**: `publicly_accessible = false` +- **Security Group**: Only allows traffic from tenant base SG +- **TLS Required**: Certificate validation enforced +- **IAM Auth**: Token-based authentication available + +## Vulnerability Management + +### Recommendations + +1. **ECR Image Scanning**: Enabled by default (`scan_on_push = true`) +2. **Dependency Scanning**: Use Dependabot or Snyk in CI/CD +3. **tfsec**: Security scanning in GitHub Actions workflow +4. **AWS Inspector**: Enable for EC2/EKS vulnerability assessment + +## Incident Response + +### Recommendations + +1. **GuardDuty**: Enable for threat detection +2. **Security Hub**: Aggregate findings across services +3. **CloudWatch Alarms**: CPU, connections, storage alerts configured +4. **SNS Topics**: Wire alarms to PagerDuty/Slack + +## Compliance Mapping + +| Control | HIPAA | SOC 2 | ISO 27001 | HITRUST | +|---------|-------|-------|-----------|---------| +| Encryption at rest | ✓ | ✓ | ✓ | ✓ | +| Encryption in transit | ✓ | ✓ | ✓ | ✓ | +| Access logging | ✓ | ✓ | ✓ | ✓ | +| Network isolation | ✓ | ✓ | ✓ | ✓ | +| Least privilege IAM | ✓ | ✓ | ✓ | ✓ | +| Key management | ✓ | ✓ | ✓ | ✓ | + +## What's NOT Included (Configure Separately) + +- CloudTrail (account-level, usually in audit account) +- AWS Config Rules +- GuardDuty +- Security Hub +- AWS WAF (per-application decision) +- MFA enforcement (IAM policy) +- Password policies (IAM) +- Backup policies (AWS Backup) + +## Cost Considerations + +Security features with cost impact: + +| Feature | Cost Impact | Recommendation | +|---------|-------------|----------------| +| KMS keys | ~$1/mo per key | Use for production | +| VPC Flow Logs | ~$0.50/GB | Enable for compliance | +| Enhanced Monitoring | ~$0.10/instance/mo | Production only | +| Performance Insights | Free (7 days) | Always enable | +| S3 Glacier | ~$0.004/GB/mo | Use for log archival | diff --git a/live/dev/env.hcl b/live/dev/env.hcl new file mode 100644 index 0000000..8a10044 --- /dev/null +++ b/live/dev/env.hcl @@ -0,0 +1,17 @@ +# Development environment configuration +locals { + environment = "dev" + aws_region = "us-east-1" + project_name = "myproject" # Update this + + # Environment-specific settings + settings = { + multi_az = false + deletion_protection = false + backup_retention = 1 + instance_class = "db.t3.micro" + node_type = "cache.t3.micro" + min_capacity = 1 + max_capacity = 2 + } +} diff --git a/live/prod/env.hcl b/live/prod/env.hcl new file mode 100644 index 0000000..a90c783 --- /dev/null +++ b/live/prod/env.hcl @@ -0,0 +1,17 @@ +# Production environment configuration +locals { + environment = "prod" + aws_region = "us-east-1" + project_name = "myproject" # Update this + + # Environment-specific settings + settings = { + multi_az = true + deletion_protection = true + backup_retention = 35 + instance_class = "db.r6g.large" + node_type = "cache.r6g.large" + min_capacity = 2 + max_capacity = 20 + } +} diff --git a/live/shared/github-oidc/terragrunt.hcl b/live/shared/github-oidc/terragrunt.hcl new file mode 100644 index 0000000..5aac3d5 --- /dev/null +++ b/live/shared/github-oidc/terragrunt.hcl @@ -0,0 +1,143 @@ +# GitHub OIDC Configuration +# Implements AWS/Terraform/Security best practices +# +# Security features enabled: +# - Explicit repository restrictions (no wildcards) +# - Branch/environment protection +# - Session duration limits +# - Least-privilege policies +# - CloudTrail monitoring + +terraform { + source = "../../../terraform/modules/github-oidc" +} + +include "root" { + path = find_in_parent_folders("terragrunt.hcl") +} + +inputs = { + # GitHub organization + github_org = "ghndrx" # Update to your org + name_prefix = "github" + + # Security settings + path = "/github-actions/" # Isolated IAM path + max_session_hours_limit = 2 # Cap all sessions at 2 hours + deny_wildcard_repos = true # No * repos allowed + require_permissions_boundary = false # Enable in production + # permissions_boundary = "arn:aws:iam::ACCOUNT:policy/GitHubActionsBoundary" + + # Monitoring (requires CloudTrail) + enable_cloudtrail_logging = false # Set true when CloudTrail is configured + # alarm_sns_topic_arn = "arn:aws:sns:us-east-1:ACCOUNT:security-alerts" + + # Custom roles with explicit restrictions + roles = { + # Infrastructure deployment - main branch only + infra = { + repos = ["terraform-foundation", "infrastructure"] + branches = ["main"] + environments = ["production"] + policy_statements = [ + { + sid = "ReadOnly" + actions = ["ec2:Describe*", "s3:List*", "s3:Get*", "iam:Get*", "iam:List*"] + resources = ["*"] + } + ] + max_session_hours = 1 + } + + # PR validation - read-only + validate = { + repos = ["terraform-foundation"] + pull_request = true + policy_statements = [ + { + sid = "ReadOnlyValidation" + effect = "Allow" + actions = ["ec2:Describe*", "s3:List*", "iam:Get*", "iam:List*"] + resources = ["*"] + } + ] + max_session_hours = 1 + } + + # Release automation - tag-based + release = { + repos = ["terraform-foundation"] + tags = ["v*"] + branches = [] # Only tags, not branches + policy_statements = [ + { + sid = "ReleaseArtifacts" + actions = ["s3:PutObject"] + resources = ["arn:aws:s3:::release-artifacts/*"] + } + ] + } + } + + # Terraform deployment with least privilege + terraform_deploy_role = { + enabled = true + repos = ["terraform-foundation"] + branches = ["main"] + environments = ["production"] + state_bucket = "your-terraform-state-bucket" # Update + state_bucket_key_prefix = "terraform/*" # Limit to specific paths + dynamodb_table = "terraform-locks" + allowed_services = [ + "ec2", "s3", "iam", "lambda", "apigateway", + "cloudwatch", "logs", "route53", "acm" + ] + denied_actions = [ + "iam:CreateUser", + "iam:CreateAccessKey", + "iam:DeleteAccountPasswordPolicy", + "organizations:*", + "account:*", + "sts:AssumeRole" # Prevent role chaining + ] + } + + # ECR with explicit repos + ecr_push_role = { + enabled = true + repos = ["backend-api", "frontend-app"] + branches = ["main", "develop"] + ecr_repos = ["backend-api", "frontend-app"] # Explicit ECR repos + allow_create = false + allow_delete = false + } + + # S3 static sites + s3_deploy_role = { + enabled = true + repos = ["website", "docs"] + branches = ["main"] + bucket_arns = ["arn:aws:s3:::www.example.com"] # Update + allowed_prefixes = ["assets/*", "*.html", "*.js", "*.css"] + cloudfront_arns = [] # Add if using CloudFront + } + + # Lambda deployments + lambda_deploy_role = { + enabled = true + repos = ["serverless-api"] + branches = ["main"] + function_arns = [ + "arn:aws:lambda:us-east-1:*:function:api-*" # Update + ] + allow_create = false + allow_logs = true + } + + tags = { + Environment = "shared" + ManagedBy = "terraform" + Component = "github-oidc" + CostCenter = "platform" + } +} diff --git a/live/staging/env.hcl b/live/staging/env.hcl new file mode 100644 index 0000000..7b5e8a3 --- /dev/null +++ b/live/staging/env.hcl @@ -0,0 +1,17 @@ +# Staging environment configuration +locals { + environment = "staging" + aws_region = "us-east-1" + project_name = "myproject" # Update this + + # Environment-specific settings + settings = { + multi_az = false + deletion_protection = false + backup_retention = 7 + instance_class = "db.t3.small" + node_type = "cache.t3.small" + min_capacity = 1 + max_capacity = 5 + } +} diff --git a/scripts/apply-all.sh b/scripts/apply-all.sh new file mode 100755 index 0000000..600580b --- /dev/null +++ b/scripts/apply-all.sh @@ -0,0 +1,130 @@ +#!/bin/bash +################################################################################ +# Apply all Terraform layers in order +# Usage: ./scripts/apply-all.sh [plan|apply|destroy] +################################################################################ + +set -e + +ACTION="${1:-plan}" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +TF_DIR="$(dirname "$SCRIPT_DIR")/terraform" + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +# Validate action +if [[ ! "$ACTION" =~ ^(plan|apply|destroy)$ ]]; then + echo -e "${RED}Usage: $0 [plan|apply|destroy]${NC}" + exit 1 +fi + +# Check if bootstrap has been run +if [ ! -f "$TF_DIR/00-bootstrap/backend.hcl" ]; then + echo -e "${YELLOW}Warning: backend.hcl not found. Run bootstrap first:${NC}" + echo " cd terraform/00-bootstrap && terraform init && terraform apply" + + if [ "$ACTION" != "plan" ]; then + exit 1 + fi +fi + +# Read config from bootstrap if available +if [ -f "$TF_DIR/00-bootstrap/backend.hcl" ]; then + STATE_BUCKET=$(grep 'bucket' "$TF_DIR/00-bootstrap/backend.hcl" | cut -d'"' -f2) + REGION=$(grep 'region' "$TF_DIR/00-bootstrap/backend.hcl" | cut -d'"' -f2) +fi + +# Determine deployment mode (check if we have organization state) +DEPLOYMENT_MODE="single-account" +if [ -f "$TF_DIR/01-organization/.terraform/terraform.tfstate" ]; then + DEPLOYMENT_MODE="multi-account" +fi + +echo -e "${GREEN}========================================${NC}" +echo -e "${GREEN}Terraform Foundation - ${ACTION}${NC}" +echo -e "${GREEN}Mode: ${DEPLOYMENT_MODE}${NC}" +echo -e "${GREEN}========================================${NC}" + +# Define layers based on deployment mode +if [ "$DEPLOYMENT_MODE" = "multi-account" ]; then + LAYERS=("00-bootstrap" "01-organization" "02-network" "03-platform") +else + LAYERS=("00-bootstrap" "02-network" "03-platform") +fi + +# Reverse for destroy +if [ "$ACTION" = "destroy" ]; then + echo -e "${RED}⚠️ DESTROYING infrastructure in reverse order${NC}" + LAYERS=($(printf '%s\n' "${LAYERS[@]}" | tac)) +fi + +# Process each layer +for layer in "${LAYERS[@]}"; do + layer_dir="$TF_DIR/$layer" + + # Skip if main.tf doesn't exist + if [ ! -f "$layer_dir/main.tf" ]; then + echo -e "${YELLOW}Skipping $layer (no main.tf)${NC}" + continue + fi + + echo "" + echo -e "${GREEN}>>> Layer: $layer${NC}" + cd "$layer_dir" + + # Initialize + if [ "$layer" = "00-bootstrap" ]; then + terraform init -input=false + else + terraform init -input=false -backend-config=../00-bootstrap/backend.hcl 2>/dev/null || terraform init -input=false -backend=false + fi + + # Build var args + VAR_ARGS="" + if [ -n "$STATE_BUCKET" ] && [ "$layer" != "00-bootstrap" ]; then + VAR_ARGS="-var=state_bucket=$STATE_BUCKET" + fi + + # Add project_name for platform layer if we can detect it + if [ "$layer" = "03-platform" ] && [ -n "$STATE_BUCKET" ]; then + PROJECT_NAME=$(echo "$STATE_BUCKET" | sed 's/-terraform-state$//') + VAR_ARGS="$VAR_ARGS -var=project_name=$PROJECT_NAME" + fi + + # Execute action + case $ACTION in + plan) + terraform plan $VAR_ARGS + ;; + apply) + terraform apply $VAR_ARGS -auto-approve + ;; + destroy) + terraform destroy $VAR_ARGS -auto-approve + ;; + esac + + cd - > /dev/null +done + +echo "" +echo -e "${GREEN}========================================${NC}" +echo -e "${GREEN}Complete!${NC}" +echo -e "${GREEN}========================================${NC}" + +# Process tenants if applying +if [ "$ACTION" = "apply" ]; then + TENANT_DIRS=$(find "$TF_DIR/04-tenants" -maxdepth 1 -type d ! -name "_template" ! -name "04-tenants" 2>/dev/null) + if [ -n "$TENANT_DIRS" ]; then + echo "" + echo -e "${YELLOW}Tenant directories found. Apply separately:${NC}" + for tenant_dir in $TENANT_DIRS; do + tenant=$(basename "$tenant_dir") + echo " cd terraform/04-tenants/$tenant && terraform apply -var=\"state_bucket=$STATE_BUCKET\"" + done + fi +fi diff --git a/scripts/new-tenant.sh b/scripts/new-tenant.sh new file mode 100755 index 0000000..c7cfd45 --- /dev/null +++ b/scripts/new-tenant.sh @@ -0,0 +1,92 @@ +#!/bin/bash +################################################################################ +# Create a new tenant from template +# Usage: ./scripts/new-tenant.sh +################################################################################ + +set -e + +TENANT_NAME="$1" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$(dirname "$SCRIPT_DIR")" +TF_DIR="$PROJECT_DIR/terraform" +TEMPLATE_DIR="$TF_DIR/04-tenants/_template" +TENANT_DIR="$TF_DIR/04-tenants/$TENANT_NAME" + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' + +# Validate input +if [ -z "$TENANT_NAME" ]; then + echo -e "${RED}Usage: $0 ${NC}" + echo "" + echo "Tenant name requirements:" + echo " - Lowercase letters, numbers, and hyphens only" + echo " - 3-20 characters" + echo " - Must start with a letter" + exit 1 +fi + +# Validate tenant name format +if ! [[ "$TENANT_NAME" =~ ^[a-z][a-z0-9-]{2,19}$ ]]; then + echo -e "${RED}Invalid tenant name: $TENANT_NAME${NC}" + echo "Must be 3-20 chars, start with letter, contain only lowercase letters, numbers, hyphens" + exit 1 +fi + +# Check if tenant already exists +if [ -d "$TENANT_DIR" ]; then + echo -e "${RED}Tenant '$TENANT_NAME' already exists at: $TENANT_DIR${NC}" + exit 1 +fi + +# Check template exists +if [ ! -f "$TEMPLATE_DIR/main.tf" ]; then + echo -e "${RED}Template not found at: $TEMPLATE_DIR${NC}" + exit 1 +fi + +echo -e "${GREEN}Creating tenant: $TENANT_NAME${NC}" + +# Copy template +cp -r "$TEMPLATE_DIR" "$TENANT_DIR" + +# Replace placeholders in all files +find "$TENANT_DIR" -type f -name "*.tf" -exec sed -i "s//$TENANT_NAME/g" {} \; + +echo -e "${GREEN}✓ Created tenant directory: $TENANT_DIR${NC}" + +# Show next steps +echo "" +echo -e "${YELLOW}Next steps:${NC}" +echo "" +echo "1. Edit the configuration:" +echo " ${GREEN}vim $TENANT_DIR/main.tf${NC}" +echo "" +echo " Update these values:" +echo " - tenant (should be '$TENANT_NAME')" +echo " - env (prod, staging, dev)" +echo " - apps (name, port, budget, owner)" +echo " - budget (monthly total)" +echo " - alert_emails" +echo "" +echo "2. Initialize and apply:" +echo " ${GREEN}cd $TENANT_DIR${NC}" +echo " ${GREEN}terraform init -backend-config=../../00-bootstrap/backend.hcl${NC}" +echo " ${GREEN}terraform plan -var=\"state_bucket=YOUR_BUCKET\"${NC}" +echo " ${GREEN}terraform apply -var=\"state_bucket=YOUR_BUCKET\"${NC}" +echo "" +echo "3. (Optional) Create workloads for this tenant:" +echo "" +echo " ECS Service:" +echo " ${GREEN}cp -r $TF_DIR/05-workloads/_template/ecs-service $TF_DIR/05-workloads/${TENANT_NAME}-api${NC}" +echo "" +echo " Lambda Function:" +echo " ${GREEN}cp -r $TF_DIR/05-workloads/_template/lambda-function $TF_DIR/05-workloads/${TENANT_NAME}-worker${NC}" +echo "" +echo " RDS Database:" +echo " ${GREEN}cp -r $TF_DIR/05-workloads/_template/rds-database $TF_DIR/05-workloads/${TENANT_NAME}-db${NC}" +echo "" diff --git a/scripts/new-workload.sh b/scripts/new-workload.sh new file mode 100755 index 0000000..239a22c --- /dev/null +++ b/scripts/new-workload.sh @@ -0,0 +1,359 @@ +#!/bin/bash +################################################################################ +# Create a new workload from template +# Usage: ./scripts/new-workload.sh +# +# Types: ecs, lambda, rds +################################################################################ + +set -e + +TYPE="$1" +TENANT="$2" +NAME="$3" + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$(dirname "$SCRIPT_DIR")" +TF_DIR="$PROJECT_DIR/terraform" + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' + +# Input validation - prevent command injection +# Only allow lowercase letters, numbers, and hyphens +validate_name() { + local value="$1" + local field="$2" + if [[ ! "$value" =~ ^[a-z0-9]([a-z0-9-]*[a-z0-9])?$ ]]; then + echo -e "${RED}Error: $field must contain only lowercase letters, numbers, and hyphens${NC}" + echo " Must start and end with a letter or number" + echo " Got: '$value'" + exit 1 + fi + if [[ ${#value} -gt 63 ]]; then + echo -e "${RED}Error: $field must be 63 characters or less${NC}" + exit 1 + fi +} + +# Show usage +usage() { + echo "Usage: $0 " + echo "" + echo "Compute:" + echo " ecs - ECS Fargate service with ALB" + echo " eks - EKS Kubernetes cluster" + echo " lambda - Lambda function with API Gateway" + echo "" + echo "Data:" + echo " rds - RDS database (PostgreSQL/MySQL)" + echo " aurora - Aurora Serverless v2 (auto-scaling)" + echo " dynamodb - DynamoDB NoSQL table" + echo " redis - ElastiCache Redis cluster" + echo " opensearch - OpenSearch (Elasticsearch)" + echo " s3 - S3 bucket (data lake, backups, media)" + echo " ecr - ECR container registry" + echo "" + echo "API & Messaging:" + echo " apigw - API Gateway REST API" + echo " sqs - SQS queue with DLQ" + echo " sns - SNS topic (pub/sub)" + echo " eventbus - EventBridge custom event bus" + echo " events - EventBridge rules (scheduled/pattern)" + echo " stepfn - Step Functions workflow" + echo "" + echo "Auth & Email:" + echo " cognito - Cognito User Pool (auth)" + echo " ses - SES email (transactional/marketing)" + echo "" + echo "Config & Security:" + echo " secrets - Secrets Manager (credentials, API keys)" + echo " params - SSM Parameter Store (config, cheaper)" + echo "" + echo "Web:" + echo " static - Static site (S3 + CloudFront)" + echo "" + echo "Examples:" + echo " $0 ecs acme api" + echo " $0 rds acme main" + echo " $0 dynamodb acme orders" + echo " $0 eventbus acme events" + echo " $0 stepfn acme order-processor" + exit 1 +} + +# Validate input +if [ -z "$TYPE" ] || [ -z "$TENANT" ] || [ -z "$NAME" ]; then + usage +fi + +# Validate tenant and name format (security: prevent command injection) +validate_name "$TENANT" "tenant" +validate_name "$NAME" "name" + +# Map type to template directory +case $TYPE in + ecs) + TEMPLATE_DIR="$TF_DIR/05-workloads/_template/ecs-fargate" + ;; + events) + TEMPLATE_DIR="$TF_DIR/05-workloads/_template/eventbridge-rules" + ;; + eks) + TEMPLATE_DIR="$TF_DIR/05-workloads/_template/eks-cluster" + ;; + lambda) + TEMPLATE_DIR="$TF_DIR/05-workloads/_template/lambda-function" + ;; + rds) + TEMPLATE_DIR="$TF_DIR/05-workloads/_template/rds-database" + ;; + aurora) + TEMPLATE_DIR="$TF_DIR/05-workloads/_template/aurora-serverless" + ;; + dynamodb) + TEMPLATE_DIR="$TF_DIR/05-workloads/_template/dynamodb-table" + ;; + redis) + TEMPLATE_DIR="$TF_DIR/05-workloads/_template/elasticache-redis" + ;; + opensearch) + TEMPLATE_DIR="$TF_DIR/05-workloads/_template/opensearch" + ;; + s3) + TEMPLATE_DIR="$TF_DIR/05-workloads/_template/s3-bucket" + ;; + ecr) + TEMPLATE_DIR="$TF_DIR/05-workloads/_template/ecr-repository" + ;; + sns) + TEMPLATE_DIR="$TF_DIR/05-workloads/_template/sns-topic" + ;; + params) + TEMPLATE_DIR="$TF_DIR/05-workloads/_template/ssm-parameters" + ;; + cognito) + TEMPLATE_DIR="$TF_DIR/05-workloads/_template/cognito-auth" + ;; + ses) + TEMPLATE_DIR="$TF_DIR/05-workloads/_template/ses-email" + ;; + secrets) + TEMPLATE_DIR="$TF_DIR/05-workloads/_template/secrets-manager" + ;; + apigw) + TEMPLATE_DIR="$TF_DIR/05-workloads/_template/api-gateway" + ;; + sqs) + TEMPLATE_DIR="$TF_DIR/05-workloads/_template/sqs-queue" + ;; + eventbus) + TEMPLATE_DIR="$TF_DIR/05-workloads/_template/eventbridge-bus" + ;; + stepfn) + TEMPLATE_DIR="$TF_DIR/05-workloads/_template/step-function" + ;; + static) + TEMPLATE_DIR="$TF_DIR/05-workloads/_template/static-site" + ;; + *) + echo -e "${RED}Unknown type: $TYPE${NC}" + usage + ;; +esac + +WORKLOAD_NAME="${TENANT}-${NAME}" +WORKLOAD_DIR="$TF_DIR/05-workloads/$WORKLOAD_NAME" + +# Check if workload already exists +if [ -d "$WORKLOAD_DIR" ]; then + echo -e "${RED}Workload '$WORKLOAD_NAME' already exists at: $WORKLOAD_DIR${NC}" + exit 1 +fi + +# Check template exists +if [ ! -f "$TEMPLATE_DIR/main.tf" ]; then + echo -e "${RED}Template not found at: $TEMPLATE_DIR${NC}" + exit 1 +fi + +# Check tenant exists +if [ ! -d "$TF_DIR/04-tenants/$TENANT" ] && [ "$TENANT" != "_template" ]; then + echo -e "${YELLOW}Warning: Tenant '$TENANT' doesn't exist yet.${NC}" + echo "Create it first: ./scripts/new-tenant.sh $TENANT" + echo "" + read -p "Continue anyway? [y/N] " -n 1 -r + echo + if [[ ! $REPLY =~ ^[Yy]$ ]]; then + exit 1 + fi +fi + +echo -e "${GREEN}Creating workload: $WORKLOAD_NAME (type: $TYPE)${NC}" + +# Copy template +cp -r "$TEMPLATE_DIR" "$WORKLOAD_DIR" + +# Replace placeholders +find "$WORKLOAD_DIR" -type f -name "*.tf" -exec sed -i "s//$TENANT/g" {} \; +find "$WORKLOAD_DIR" -type f -name "*.tf" -exec sed -i "s//$NAME/g" {} \; + +echo -e "${GREEN}✓ Created workload directory: $WORKLOAD_DIR${NC}" + +# Type-specific instructions +echo "" +echo -e "${YELLOW}Next steps:${NC}" +echo "" +echo "1. Edit the configuration:" +echo " ${GREEN}vim $WORKLOAD_DIR/main.tf${NC}" +echo "" + +case $TYPE in + ecs) + echo " Update these values:" + echo " - container_image (ECR URL)" + echo " - container_port" + echo " - cpu, memory" + echo " - desired_count, min_count, max_count" + echo " - environment variables" + echo " - secrets (ARNs)" + ;; + eks) + echo " Update these values:" + echo " - cluster_version (1.29, 1.30, etc.)" + echo " - node_groups (instance types, scaling config)" + echo " - enable_fargate (for serverless pods)" + echo " - admin_arns (IAM principals for cluster access)" + echo " - cluster_endpoint_public (false for private-only)" + echo "" + echo " After apply, configure kubectl:" + echo " aws eks update-kubeconfig --name ${TENANT}-prod" + ;; + lambda) + echo " Update these values:" + echo " - runtime (python3.12, nodejs20.x, etc.)" + echo " - handler" + echo " - source_dir OR s3_bucket/s3_key OR image_uri" + echo " - enable_vpc (true for database access)" + echo " - enable_api (true for HTTP endpoint)" + echo " - schedule_expression (for cron jobs)" + ;; + rds) + echo " Update these values:" + echo " - engine (postgres, mysql, aurora-postgresql)" + echo " - engine_version" + echo " - instance_class" + echo " - storage_gb" + echo " - multi_az (true for prod)" + ;; + redis) + echo " Update these values:" + echo " - engine_version (7.1, 7.0, etc.)" + echo " - node_type (cache.t3.micro, cache.r6g.large)" + echo " - num_cache_clusters (2 for Multi-AZ)" + echo " - maxmemory_policy (volatile-lru, allkeys-lru)" + ;; + s3) + echo " Update these values:" + echo " - lifecycle_rules (tiering, expiration)" + echo " - enable_replication (cross-region DR)" + echo " - lambda_notifications (event triggers)" + echo " - cors_enabled (for web access)" + ;; + ecr) + echo " Update these values:" + echo " - repositories (map of repo names)" + echo " - lifecycle_policy (cleanup rules)" + echo " - pull_access_accounts (cross-account)" + echo " - replication_regions (multi-region)" + ;; + sns) + echo " Update these values:" + echo " - subscriptions (Lambda, SQS, Email, HTTP)" + echo " - filter_policy (message filtering)" + echo " - fifo_topic (ordered delivery)" + echo " - aws_service_principals (EventBridge, S3)" + ;; + params) + echo " Update these values:" + echo " - parameters map (path -> value)" + echo " - SecureString for sensitive values" + echo " - Free for standard tier (4KB limit)" + echo " - Cheaper than Secrets Manager" + ;; + cognito) + echo " Update these values:" + echo " - app_clients (web, mobile, m2m)" + echo " - password policy, MFA settings" + echo " - social_providers (Google, Facebook)" + echo " - custom_domain, lambda_triggers" + ;; + ses) + echo " Update these values:" + echo " - domain, hosted_zone_id" + echo " - email_identities (sender addresses)" + echo " - tracking_options (open/click tracking)" + echo " - DMARC policy" + ;; + secrets) + echo " Update these values:" + echo " - secrets map (name -> config)" + echo " - generate_password for auto-generated creds" + echo " - rotation settings for RDS" + echo " - allowed_accounts for cross-account" + ;; + apigw) + echo " Update these values:" + echo " - lambda_integrations (path -> Lambda ARN)" + echo " - domain_name, hosted_zone_id (custom domain)" + echo " - usage_plans (quota/throttle)" + echo " - cors_origins (CORS allowed origins)" + ;; + sqs) + echo " Update these values:" + echo " - fifo_queue (true for exactly-once processing)" + echo " - visibility_timeout_seconds" + echo " - max_receive_count (DLQ threshold)" + echo " - message_retention_seconds" + ;; + dynamodb) + echo " Update these values:" + echo " - hash_key, range_key (primary key)" + echo " - billing_mode (PAY_PER_REQUEST or PROVISIONED)" + echo " - global_secondary_indexes" + echo " - ttl_attribute (for auto-expiry)" + ;; + eventbus) + echo " Update these values:" + echo " - event_rules (pattern matching and targets)" + echo " - enable_archive (for event replay)" + echo " - allowed_source_accounts (cross-account)" + ;; + stepfn) + echo " Update these values:" + echo " - state_machine_definition (workflow JSON)" + echo " - type (STANDARD or EXPRESS)" + echo " - lambda_arns, dynamodb_arns, etc. (permissions)" + echo " - schedule_expression (for scheduled runs)" + ;; + static) + echo " Update these values:" + echo " - domain_name (e.g., www.example.com)" + echo " - hosted_zone_id (Route53 zone)" + echo " - price_class (PriceClass_100 cheapest)" + echo "" + echo " Deploy content:" + echo " aws s3 sync ./dist s3://BUCKET --delete" + ;; +esac + +echo "" +echo "2. Initialize and apply:" +echo " ${GREEN}cd $WORKLOAD_DIR${NC}" +echo " ${GREEN}terraform init -backend-config=../../00-bootstrap/backend.hcl${NC}" +echo " ${GREEN}terraform plan -var=\"state_bucket=YOUR_BUCKET\"${NC}" +echo " ${GREEN}terraform apply -var=\"state_bucket=YOUR_BUCKET\"${NC}" +echo "" diff --git a/terraform/00-bootstrap/main.tf b/terraform/00-bootstrap/main.tf new file mode 100644 index 0000000..49eec39 --- /dev/null +++ b/terraform/00-bootstrap/main.tf @@ -0,0 +1,385 @@ +################################################################################ +# Layer 00: Bootstrap +# +# First layer - creates foundational resources needed by all other layers: +# - Terraform state bucket +# - DynamoDB lock table +# - KMS key for encryption +# +# Supports two deployment modes: +# - single-account: Everything in one account (small scale / startup) +# - multi-account: Separate accounts per environment (enterprise) +# +# Run: terraform init && terraform apply +# Next: 01-organization (multi-account) or 02-network (single-account) +################################################################################ + +terraform { + required_version = ">= 1.5" + + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 5.0" + } + } + + # First run uses local state, then migrate to S3 + # backend "s3" { + # bucket = "your-org-terraform-state" + # key = "00-bootstrap/terraform.tfstate" + # region = "us-east-1" + # dynamodb_table = "terraform-locks" + # encrypt = true + # } +} + +provider "aws" { + region = var.region + + default_tags { + tags = { + Layer = "00-bootstrap" + ManagedBy = "terraform" + Project = var.project_name + } + } +} + +################################################################################ +# Variables +################################################################################ + +variable "region" { + description = "AWS region" + type = string + default = "us-east-1" +} + +variable "project_name" { + description = "Project name (used for naming resources)" + type = string +} + +variable "deployment_mode" { + description = "Deployment mode: 'single-account' or 'multi-account'" + type = string + default = "single-account" + + validation { + condition = contains(["single-account", "multi-account"], var.deployment_mode) + error_message = "deployment_mode must be 'single-account' or 'multi-account'" + } +} + +################################################################################ +# S3 Bucket for Terraform State +################################################################################ + +resource "aws_s3_bucket" "terraform_state" { + bucket = "${var.project_name}-terraform-state" + + lifecycle { + prevent_destroy = true + } + + tags = { + Name = "Terraform State" + } +} + +resource "aws_s3_bucket_versioning" "terraform_state" { + bucket = aws_s3_bucket.terraform_state.id + + versioning_configuration { + status = "Enabled" + } +} + +resource "aws_s3_bucket_server_side_encryption_configuration" "terraform_state" { + bucket = aws_s3_bucket.terraform_state.id + + rule { + apply_server_side_encryption_by_default { + sse_algorithm = "aws:kms" + kms_master_key_id = aws_kms_key.terraform.arn + } + } +} + +resource "aws_s3_bucket_public_access_block" "terraform_state" { + bucket = aws_s3_bucket.terraform_state.id + + block_public_acls = true + block_public_policy = true + ignore_public_acls = true + restrict_public_buckets = true +} + +################################################################################ +# S3 Bucket for Access Logs (Audit Trail) +################################################################################ + +resource "aws_s3_bucket" "logs" { + bucket = "${var.project_name}-logs-${data.aws_caller_identity.current.account_id}" + + tags = { + Name = "Access Logs" + } +} + +resource "aws_s3_bucket_versioning" "logs" { + bucket = aws_s3_bucket.logs.id + + versioning_configuration { + status = "Enabled" + } +} + +resource "aws_s3_bucket_server_side_encryption_configuration" "logs" { + bucket = aws_s3_bucket.logs.id + + rule { + apply_server_side_encryption_by_default { + sse_algorithm = "AES256" # S3-managed keys for log delivery compatibility + } + } +} + +resource "aws_s3_bucket_public_access_block" "logs" { + bucket = aws_s3_bucket.logs.id + + block_public_acls = true + block_public_policy = true + ignore_public_acls = true + restrict_public_buckets = true +} + +resource "aws_s3_bucket_lifecycle_configuration" "logs" { + bucket = aws_s3_bucket.logs.id + + rule { + id = "transition-to-glacier" + status = "Enabled" + + transition { + days = 90 + storage_class = "GLACIER" + } + + expiration { + days = 2555 # 7 years for compliance + } + } +} + +# Policy to allow various AWS services to write logs +resource "aws_s3_bucket_policy" "logs" { + bucket = aws_s3_bucket.logs.id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Sid = "AllowSSLRequestsOnly" + Effect = "Deny" + Principal = "*" + Action = "s3:*" + Resource = [ + aws_s3_bucket.logs.arn, + "${aws_s3_bucket.logs.arn}/*" + ] + Condition = { + Bool = { + "aws:SecureTransport" = "false" + } + } + }, + { + Sid = "AWSLogDeliveryWrite" + Effect = "Allow" + Principal = { + Service = "delivery.logs.amazonaws.com" + } + Action = "s3:PutObject" + Resource = "${aws_s3_bucket.logs.arn}/*" + Condition = { + StringEquals = { + "s3:x-amz-acl" = "bucket-owner-full-control" + "aws:SourceAccount" = data.aws_caller_identity.current.account_id + } + } + }, + { + Sid = "AWSLogDeliveryCheck" + Effect = "Allow" + Principal = { + Service = "delivery.logs.amazonaws.com" + } + Action = ["s3:GetBucketAcl", "s3:ListBucket"] + Resource = aws_s3_bucket.logs.arn + Condition = { + StringEquals = { + "aws:SourceAccount" = data.aws_caller_identity.current.account_id + } + } + }, + { + Sid = "ELBLogDelivery" + Effect = "Allow" + Principal = { + AWS = "arn:aws:iam::${local.elb_account_id}:root" + } + Action = "s3:PutObject" + Resource = "${aws_s3_bucket.logs.arn}/alb/*" + } + ] + }) +} + +# ELB account IDs by region (for ALB access logging) +locals { + elb_account_ids = { + us-east-1 = "127311923021" + us-east-2 = "033677994240" + us-west-1 = "027434742980" + us-west-2 = "797873946194" + eu-west-1 = "156460612806" + eu-west-2 = "652711504416" + eu-central-1 = "054676820928" + ap-southeast-1 = "114774131450" + ap-southeast-2 = "783225319266" + ap-northeast-1 = "582318560864" + } + elb_account_id = lookup(local.elb_account_ids, var.region, "127311923021") +} + +data "aws_caller_identity" "current" {} + +################################################################################ +# DynamoDB Table for State Locking +################################################################################ + +resource "aws_dynamodb_table" "terraform_locks" { + name = "${var.project_name}-terraform-locks" + billing_mode = "PAY_PER_REQUEST" + hash_key = "LockID" + + attribute { + name = "LockID" + type = "S" + } + + # Encryption at rest with AWS managed key (free) or use KMS for compliance + server_side_encryption { + enabled = true + kms_key_arn = aws_kms_key.terraform.arn + } + + # Point-in-time recovery for compliance + point_in_time_recovery { + enabled = true + } + + tags = { + Name = "Terraform Lock Table" + } +} + +################################################################################ +# KMS Key for State Encryption +################################################################################ + +resource "aws_kms_key" "terraform" { + description = "KMS key for Terraform state encryption" + deletion_window_in_days = 30 + enable_key_rotation = true + + tags = { + Name = "Terraform State Key" + } +} + +resource "aws_kms_alias" "terraform" { + name = "alias/${var.project_name}-terraform" + target_key_id = aws_kms_key.terraform.key_id +} + +################################################################################ +# Outputs +################################################################################ + +output "state_bucket" { + value = aws_s3_bucket.terraform_state.id +} + +output "lock_table" { + value = aws_dynamodb_table.terraform_locks.id +} + +output "kms_key_arn" { + value = aws_kms_key.terraform.arn +} + +output "region" { + value = var.region +} + +output "project_name" { + value = var.project_name +} + +output "deployment_mode" { + value = var.deployment_mode +} + +output "logs_bucket" { + value = aws_s3_bucket.logs.id +} + +output "logs_bucket_arn" { + value = aws_s3_bucket.logs.arn +} + +################################################################################ +# Backend Config Generator +################################################################################ + +resource "local_file" "backend_config" { + filename = "${path.module}/backend.hcl" + content = <<-EOT + bucket = "${aws_s3_bucket.terraform_state.id}" + region = "${var.region}" + dynamodb_table = "${aws_dynamodb_table.terraform_locks.id}" + encrypt = true + EOT +} + +################################################################################ +# Next Steps +################################################################################ + +output "next_steps" { + value = var.deployment_mode == "single-account" ? <<-EOT + + Single-Account Mode Selected + ============================ + Skip 01-organization, go directly to: + + cd ../02-network + terraform init -backend-config=../00-bootstrap/backend.hcl + terraform apply -var="state_bucket=${aws_s3_bucket.terraform_state.id}" -var="deployment_mode=single-account" + + EOT + : <<-EOT + + Multi-Account Mode Selected + =========================== + Next step: + + cd ../01-organization + terraform init -backend-config=../00-bootstrap/backend.hcl + terraform apply + + EOT +} diff --git a/terraform/00-bootstrap/terraform.tfvars.example b/terraform/00-bootstrap/terraform.tfvars.example new file mode 100644 index 0000000..1ef85dd --- /dev/null +++ b/terraform/00-bootstrap/terraform.tfvars.example @@ -0,0 +1,6 @@ +# Example variables for 00-bootstrap layer +# Copy to terraform.tfvars and update values + +project_name = "myproject" +deployment_mode = "single-account" # or "multi-account" +region = "us-east-1" diff --git a/terraform/01-organization/main.tf b/terraform/01-organization/main.tf new file mode 100644 index 0000000..1e3997f --- /dev/null +++ b/terraform/01-organization/main.tf @@ -0,0 +1,572 @@ +################################################################################ +# Layer 01: Organization (Multi-Account Mode Only) +# +# Creates: +# - AWS Organization with SCPs and Tag Policies +# - OUs: Security, Infrastructure, Platform, Workloads, Sandbox +# - Core accounts: audit, log-archive, network +# - Service Control Policies +# +# Depends on: 00-bootstrap +################################################################################ + +terraform { + required_version = ">= 1.5" + + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 5.0" + } + } + + backend "s3" { + key = "01-organization/terraform.tfstate" + } +} + +provider "aws" { + region = var.region + + default_tags { + tags = { + Layer = "01-organization" + ManagedBy = "terraform" + } + } +} + +################################################################################ +# Variables +################################################################################ + +variable "region" { + type = string + default = "us-east-1" +} + +variable "email_domain" { + description = "Domain for account emails (e.g., example.com)" + type = string +} + +variable "email_prefix" { + description = "Prefix for account emails" + type = string + default = "aws" +} + +variable "allowed_regions" { + type = list(string) + default = ["us-east-1", "us-west-2"] +} + +################################################################################ +# Organization +################################################################################ + +resource "aws_organizations_organization" "main" { + feature_set = "ALL" + + enabled_policy_types = [ + "SERVICE_CONTROL_POLICY", + "TAG_POLICY", + ] + + aws_service_access_principals = [ + "cloudtrail.amazonaws.com", + "config.amazonaws.com", + "guardduty.amazonaws.com", + "ram.amazonaws.com", + "sso.amazonaws.com", + ] +} + +################################################################################ +# Organizational Units +################################################################################ + +resource "aws_organizations_organizational_unit" "security" { + name = "Security" + parent_id = aws_organizations_organization.main.roots[0].id +} + +resource "aws_organizations_organizational_unit" "infrastructure" { + name = "Infrastructure" + parent_id = aws_organizations_organization.main.roots[0].id +} + +resource "aws_organizations_organizational_unit" "platform" { + name = "Platform" + parent_id = aws_organizations_organization.main.roots[0].id +} + +resource "aws_organizations_organizational_unit" "workloads" { + name = "Workloads" + parent_id = aws_organizations_organization.main.roots[0].id +} + +resource "aws_organizations_organizational_unit" "prod" { + name = "Production" + parent_id = aws_organizations_organizational_unit.workloads.id +} + +resource "aws_organizations_organizational_unit" "nonprod" { + name = "Non-Production" + parent_id = aws_organizations_organizational_unit.workloads.id +} + +resource "aws_organizations_organizational_unit" "sandbox" { + name = "Sandbox" + parent_id = aws_organizations_organization.main.roots[0].id +} + +################################################################################ +# Core Accounts +################################################################################ + +resource "aws_organizations_account" "audit" { + name = "audit" + email = "${var.email_prefix}+audit@${var.email_domain}" + parent_id = aws_organizations_organizational_unit.security.id + role_name = "OrganizationAccountAccessRole" + + lifecycle { ignore_changes = [role_name] } +} + +resource "aws_organizations_account" "log_archive" { + name = "log-archive" + email = "${var.email_prefix}+logs@${var.email_domain}" + parent_id = aws_organizations_organizational_unit.security.id + role_name = "OrganizationAccountAccessRole" + + lifecycle { ignore_changes = [role_name] } +} + +resource "aws_organizations_account" "network" { + name = "network" + email = "${var.email_prefix}+network@${var.email_domain}" + parent_id = aws_organizations_organizational_unit.infrastructure.id + role_name = "OrganizationAccountAccessRole" + + lifecycle { ignore_changes = [role_name] } +} + +################################################################################ +# SCPs - Security Baseline +################################################################################ + +# Deny root user access in member accounts +resource "aws_organizations_policy" "deny_root" { + name = "deny-root" + description = "Deny all actions by the root user in member accounts" + type = "SERVICE_CONTROL_POLICY" + + content = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Sid = "DenyRoot" + Effect = "Deny" + Action = "*" + Resource = "*" + Condition = { StringLike = { "aws:PrincipalArn" = "arn:aws:iam::*:root" } } + }] + }) +} + +resource "aws_organizations_policy_attachment" "deny_root" { + policy_id = aws_organizations_policy.deny_root.id + target_id = aws_organizations_organizational_unit.workloads.id +} + +# Restrict to approved regions +resource "aws_organizations_policy" "restrict_regions" { + name = "restrict-regions" + description = "Restrict resource creation to approved regions" + type = "SERVICE_CONTROL_POLICY" + + content = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Sid = "DenyOtherRegions" + Effect = "Deny" + NotAction = [ + "iam:*", + "organizations:*", + "support:*", + "sts:*", + "cloudfront:*", + "route53:*", + "route53domains:*", + "budgets:*", + "ce:*", + "waf:*", + "wafv2:*", + "health:*", + "globalaccelerator:*", + "importexport:*", + "pricing:*", + "trustedadvisor:*" + ] + Resource = "*" + Condition = { StringNotEquals = { "aws:RequestedRegion" = var.allowed_regions } } + }] + }) +} + +resource "aws_organizations_policy_attachment" "restrict_regions" { + policy_id = aws_organizations_policy.restrict_regions.id + target_id = aws_organizations_organizational_unit.workloads.id +} + +# Require tags on resource creation +resource "aws_organizations_policy" "require_tags" { + name = "require-tags" + description = "Require Tenant and Environment tags on resource creation" + type = "SERVICE_CONTROL_POLICY" + + content = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Sid = "RequireTags" + Effect = "Deny" + Action = [ + "ec2:RunInstances", + "ec2:CreateVolume", + "ec2:CreateSecurityGroup", + "rds:CreateDBInstance", + "rds:CreateDBCluster", + "s3:CreateBucket", + "lambda:CreateFunction", + "ecs:CreateCluster", + "eks:CreateCluster", + "elasticache:CreateCacheCluster", + "sqs:CreateQueue", + "sns:CreateTopic" + ] + Resource = "*" + Condition = { + Null = { + "aws:RequestTag/Tenant" = "true" + "aws:RequestTag/Environment" = "true" + } + } + }] + }) +} + +resource "aws_organizations_policy_attachment" "require_tags" { + policy_id = aws_organizations_policy.require_tags.id + target_id = aws_organizations_organizational_unit.workloads.id +} + +################################################################################ +# SCPs - Data Protection +################################################################################ + +# Require encryption on S3 buckets +resource "aws_organizations_policy" "require_s3_encryption" { + name = "require-s3-encryption" + description = "Deny unencrypted S3 object uploads" + type = "SERVICE_CONTROL_POLICY" + + content = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Sid = "DenyUnencryptedUploads" + Effect = "Deny" + Action = "s3:PutObject" + Resource = "*" + Condition = { + Null = { + "s3:x-amz-server-side-encryption" = "true" + } + } + }, + { + Sid = "DenyNonAESEncryption" + Effect = "Deny" + Action = "s3:PutObject" + Resource = "*" + Condition = { + StringNotEqualsIfExists = { + "s3:x-amz-server-side-encryption" = ["AES256", "aws:kms"] + } + } + } + ] + }) +} + +resource "aws_organizations_policy_attachment" "require_s3_encryption" { + policy_id = aws_organizations_policy.require_s3_encryption.id + target_id = aws_organizations_organizational_unit.workloads.id +} + +# Prevent disabling of encryption +resource "aws_organizations_policy" "protect_encryption" { + name = "protect-encryption" + description = "Prevent disabling encryption on critical services" + type = "SERVICE_CONTROL_POLICY" + + content = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Sid = "DenyUnencryptedRDS" + Effect = "Deny" + Action = [ + "rds:CreateDBInstance", + "rds:CreateDBCluster" + ] + Resource = "*" + Condition = { + Bool = { + "rds:StorageEncrypted" = "false" + } + } + }, + { + Sid = "DenyUnencryptedEBS" + Effect = "Deny" + Action = [ + "ec2:CreateVolume", + "ec2:RunInstances" + ] + Resource = "arn:aws:ec2:*:*:volume/*" + Condition = { + Bool = { + "ec2:Encrypted" = "false" + } + } + } + ] + }) +} + +resource "aws_organizations_policy_attachment" "protect_encryption" { + policy_id = aws_organizations_policy.protect_encryption.id + target_id = aws_organizations_organizational_unit.workloads.id +} + +################################################################################ +# SCPs - Network Security +################################################################################ + +# Prevent public access +resource "aws_organizations_policy" "deny_public_access" { + name = "deny-public-access" + description = "Prevent creation of publicly accessible resources" + type = "SERVICE_CONTROL_POLICY" + + content = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Sid = "DenyPublicRDS" + Effect = "Deny" + Action = [ + "rds:CreateDBInstance", + "rds:ModifyDBInstance" + ] + Resource = "*" + Condition = { + Bool = { + "rds:PubliclyAccessible" = "true" + } + } + }, + { + Sid = "DenyPublicS3" + Effect = "Deny" + Action = [ + "s3:PutBucketPublicAccessBlock", + "s3:DeleteBucketPublicAccessBlock" + ] + Resource = "*" + Condition = { + StringNotEquals = { + "s3:BlockPublicAcls" = "true" + "s3:BlockPublicPolicy" = "true" + "s3:IgnorePublicAcls" = "true" + "s3:RestrictPublicBuckets" = "true" + } + } + } + ] + }) +} + +resource "aws_organizations_policy_attachment" "deny_public_access" { + policy_id = aws_organizations_policy.deny_public_access.id + target_id = aws_organizations_organizational_unit.workloads.id +} + +# Require IMDSv2 +resource "aws_organizations_policy" "require_imdsv2" { + name = "require-imdsv2" + description = "Require IMDSv2 for EC2 instances" + type = "SERVICE_CONTROL_POLICY" + + content = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Sid = "RequireIMDSv2" + Effect = "Deny" + Action = "ec2:RunInstances" + Resource = "arn:aws:ec2:*:*:instance/*" + Condition = { + StringNotEquals = { + "ec2:MetadataHttpTokens" = "required" + } + } + }] + }) +} + +resource "aws_organizations_policy_attachment" "require_imdsv2" { + policy_id = aws_organizations_policy.require_imdsv2.id + target_id = aws_organizations_organizational_unit.workloads.id +} + +################################################################################ +# SCPs - Audit Protection +################################################################################ + +# Protect CloudTrail and GuardDuty +resource "aws_organizations_policy" "protect_security_services" { + name = "protect-security-services" + description = "Prevent disabling of security monitoring services" + type = "SERVICE_CONTROL_POLICY" + + content = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Sid = "ProtectCloudTrail" + Effect = "Deny" + Action = [ + "cloudtrail:DeleteTrail", + "cloudtrail:StopLogging", + "cloudtrail:UpdateTrail", + "cloudtrail:PutEventSelectors" + ] + Resource = "*" + }, + { + Sid = "ProtectGuardDuty" + Effect = "Deny" + Action = [ + "guardduty:DeleteDetector", + "guardduty:DisassociateFromMasterAccount", + "guardduty:DeleteMembers", + "guardduty:StopMonitoringMembers" + ] + Resource = "*" + }, + { + Sid = "ProtectConfig" + Effect = "Deny" + Action = [ + "config:DeleteConfigRule", + "config:DeleteConfigurationRecorder", + "config:DeleteDeliveryChannel", + "config:StopConfigurationRecorder" + ] + Resource = "*" + }, + { + Sid = "ProtectSecurityHub" + Effect = "Deny" + Action = [ + "securityhub:DisableSecurityHub", + "securityhub:DeleteMembers", + "securityhub:DisassociateFromMasterAccount" + ] + Resource = "*" + } + ] + }) +} + +resource "aws_organizations_policy_attachment" "protect_security_services" { + policy_id = aws_organizations_policy.protect_security_services.id + target_id = aws_organizations_organization.main.roots[0].id +} + +################################################################################ +# SCPs - Sandbox (Relaxed Controls) +################################################################################ + +# More permissive policy for sandbox accounts +resource "aws_organizations_policy" "sandbox_controls" { + name = "sandbox-controls" + description = "Relaxed controls for sandbox experimentation" + type = "SERVICE_CONTROL_POLICY" + + content = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Sid = "AllowAll" + Effect = "Allow" + Action = "*" + Resource = "*" + }] + }) +} + +resource "aws_organizations_policy_attachment" "sandbox_controls" { + policy_id = aws_organizations_policy.sandbox_controls.id + target_id = aws_organizations_organizational_unit.sandbox.id +} + +################################################################################ +# Tag Policy +################################################################################ + +resource "aws_organizations_policy" "tags" { + name = "mandatory-tags" + type = "TAG_POLICY" + + content = jsonencode({ + tags = { + Tenant = { tag_key = { "@@assign" = "Tenant" } } + Environment = { tag_key = { "@@assign" = "Environment" }, tag_value = { "@@assign" = ["prod", "staging", "dev", "sandbox"] } } + App = { tag_key = { "@@assign" = "App" } } + } + }) +} + +resource "aws_organizations_policy_attachment" "tags" { + policy_id = aws_organizations_policy.tags.id + target_id = aws_organizations_organization.main.roots[0].id +} + +################################################################################ +# Outputs +################################################################################ + +output "organization_id" { + value = aws_organizations_organization.main.id +} + +output "ou_ids" { + value = { + security = aws_organizations_organizational_unit.security.id + infrastructure = aws_organizations_organizational_unit.infrastructure.id + platform = aws_organizations_organizational_unit.platform.id + workloads = aws_organizations_organizational_unit.workloads.id + production = aws_organizations_organizational_unit.prod.id + nonproduction = aws_organizations_organizational_unit.nonprod.id + sandbox = aws_organizations_organizational_unit.sandbox.id + } +} + +output "account_ids" { + value = { + audit = aws_organizations_account.audit.id + log_archive = aws_organizations_account.log_archive.id + network = aws_organizations_account.network.id + } +} diff --git a/terraform/01-organization/terraform.tfvars.example b/terraform/01-organization/terraform.tfvars.example new file mode 100644 index 0000000..8d83148 --- /dev/null +++ b/terraform/01-organization/terraform.tfvars.example @@ -0,0 +1,12 @@ +# Copy to terraform.tfvars and customize + +# Email domain for AWS account emails +# Accounts will be: aws+audit@domain.com, aws+logs@domain.com, etc. +email_domain = "example.com" +email_prefix = "aws" + +# Allowed AWS regions (enforced by SCP) +allowed_regions = ["us-east-1", "us-west-2"] + +# AWS Region +region = "us-east-1" diff --git a/terraform/02-network/main.tf b/terraform/02-network/main.tf new file mode 100644 index 0000000..b41ca1d --- /dev/null +++ b/terraform/02-network/main.tf @@ -0,0 +1,305 @@ +################################################################################ +# Layer 02: Network +# +# Creates shared VPC: +# - Single VPC (cost optimized) +# - Public/Private subnets +# - Single NAT Gateway +# - AWS RAM sharing (multi-account only) +# +# Depends on: 00-bootstrap (single) or 01-organization (multi) +################################################################################ + +terraform { + required_version = ">= 1.5" + + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 5.0" + } + } + + backend "s3" { + key = "02-network/terraform.tfstate" + } +} + +################################################################################ +# Variables +################################################################################ + +variable "region" { + type = string + default = "us-east-1" +} + +variable "state_bucket" { + type = string +} + +variable "deployment_mode" { + type = string + default = "single-account" + + validation { + condition = contains(["single-account", "multi-account"], var.deployment_mode) + error_message = "Must be single-account or multi-account" + } +} + +variable "vpc_cidr" { + type = string + default = "10.0.0.0/16" +} + +variable "azs" { + type = list(string) + default = ["us-east-1a", "us-east-1b"] +} + +variable "enable_nat" { + type = bool + default = true +} + +################################################################################ +# Data Sources +################################################################################ + +data "terraform_remote_state" "org" { + count = var.deployment_mode == "multi-account" ? 1 : 0 + backend = "s3" + config = { + bucket = var.state_bucket + key = "01-organization/terraform.tfstate" + region = var.region + } +} + +################################################################################ +# Provider +################################################################################ + +provider "aws" { + region = var.region + + default_tags { + tags = { + Layer = "02-network" + ManagedBy = "terraform" + } + } +} + +################################################################################ +# VPC +################################################################################ + +resource "aws_vpc" "main" { + cidr_block = var.vpc_cidr + enable_dns_hostnames = true + enable_dns_support = true + + tags = { Name = "shared-vpc" } +} + +resource "aws_internet_gateway" "main" { + vpc_id = aws_vpc.main.id + tags = { Name = "shared-igw" } +} + +################################################################################ +# Subnets +################################################################################ + +resource "aws_subnet" "public" { + count = length(var.azs) + vpc_id = aws_vpc.main.id + cidr_block = cidrsubnet(var.vpc_cidr, 4, count.index) + availability_zone = var.azs[count.index] + map_public_ip_on_launch = true + + tags = { Name = "public-${var.azs[count.index]}", Type = "public" } +} + +resource "aws_subnet" "private" { + count = length(var.azs) + vpc_id = aws_vpc.main.id + cidr_block = cidrsubnet(var.vpc_cidr, 4, count.index + 4) + availability_zone = var.azs[count.index] + + tags = { Name = "private-${var.azs[count.index]}", Type = "private" } +} + +################################################################################ +# NAT Gateway +################################################################################ + +resource "aws_eip" "nat" { + count = var.enable_nat ? 1 : 0 + domain = "vpc" + tags = { Name = "nat-eip" } +} + +resource "aws_nat_gateway" "main" { + count = var.enable_nat ? 1 : 0 + allocation_id = aws_eip.nat[0].id + subnet_id = aws_subnet.public[0].id + + tags = { Name = "shared-nat" } + depends_on = [aws_internet_gateway.main] +} + +################################################################################ +# Route Tables +################################################################################ + +resource "aws_route_table" "public" { + vpc_id = aws_vpc.main.id + + route { + cidr_block = "0.0.0.0/0" + gateway_id = aws_internet_gateway.main.id + } + + tags = { Name = "public-rt" } +} + +resource "aws_route_table" "private" { + vpc_id = aws_vpc.main.id + + dynamic "route" { + for_each = var.enable_nat ? [1] : [] + content { + cidr_block = "0.0.0.0/0" + nat_gateway_id = aws_nat_gateway.main[0].id + } + } + + tags = { Name = "private-rt" } +} + +resource "aws_route_table_association" "public" { + count = length(var.azs) + subnet_id = aws_subnet.public[count.index].id + route_table_id = aws_route_table.public.id +} + +resource "aws_route_table_association" "private" { + count = length(var.azs) + subnet_id = aws_subnet.private[count.index].id + route_table_id = aws_route_table.private.id +} + +################################################################################ +# Default SG - Deny All +################################################################################ + +resource "aws_default_security_group" "default" { + vpc_id = aws_vpc.main.id + tags = { Name = "default-deny-all" } +} + +################################################################################ +# VPC Flow Logs (Audit Trail) +################################################################################ + +resource "aws_flow_log" "main" { + vpc_id = aws_vpc.main.id + traffic_type = "ALL" + log_destination_type = "cloud-watch-logs" + log_destination = aws_cloudwatch_log_group.flow_logs.arn + iam_role_arn = aws_iam_role.flow_logs.arn + max_aggregation_interval = 60 # 1 minute for better visibility + + tags = { Name = "vpc-flow-logs" } +} + +resource "aws_cloudwatch_log_group" "flow_logs" { + name = "/aws/vpc/flow-logs" + retention_in_days = 90 + + tags = { Name = "vpc-flow-logs" } +} + +resource "aws_iam_role" "flow_logs" { + name = "vpc-flow-logs" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Effect = "Allow" + Action = "sts:AssumeRole" + Principal = { Service = "vpc-flow-logs.amazonaws.com" } + }] + }) + + tags = { Name = "vpc-flow-logs" } +} + +resource "aws_iam_role_policy" "flow_logs" { + name = "vpc-flow-logs" + role = aws_iam_role.flow_logs.id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Effect = "Allow" + Action = [ + "logs:CreateLogGroup", + "logs:CreateLogStream", + "logs:PutLogEvents", + "logs:DescribeLogGroups", + "logs:DescribeLogStreams" + ] + Resource = "*" + }] + }) +} + +################################################################################ +# RAM Sharing (multi-account only) +################################################################################ + +resource "aws_ram_resource_share" "subnets" { + count = var.deployment_mode == "multi-account" ? 1 : 0 + name = "shared-subnets" + allow_external_principals = false +} + +resource "aws_ram_resource_association" "private" { + count = var.deployment_mode == "multi-account" ? length(var.azs) : 0 + resource_arn = aws_subnet.private[count.index].arn + resource_share_arn = aws_ram_resource_share.subnets[0].arn +} + +resource "aws_ram_principal_association" "workloads" { + count = var.deployment_mode == "multi-account" ? 1 : 0 + principal = data.terraform_remote_state.org[0].outputs.ou_ids.workloads + resource_share_arn = aws_ram_resource_share.subnets[0].arn +} + +################################################################################ +# Outputs +################################################################################ + +output "vpc_id" { + value = aws_vpc.main.id +} + +output "vpc_cidr" { + value = aws_vpc.main.cidr_block +} + +output "public_subnet_ids" { + value = aws_subnet.public[*].id +} + +output "private_subnet_ids" { + value = aws_subnet.private[*].id +} + +output "nat_ip" { + value = var.enable_nat ? aws_eip.nat[0].public_ip : null +} diff --git a/terraform/02-network/terraform.tfvars.example b/terraform/02-network/terraform.tfvars.example new file mode 100644 index 0000000..71dbb46 --- /dev/null +++ b/terraform/02-network/terraform.tfvars.example @@ -0,0 +1,37 @@ +# Example variables for 02-network layer +# Copy to terraform.tfvars and update values + +state_bucket = "myproject-terraform-state" +deployment_mode = "single-account" +region = "us-east-1" +vpc_cidr = "10.0.0.0/16" +azs = ["us-east-1a", "us-east-1b"] + +# NAT Gateway - set to false for cost savings (~$32/mo) +enable_nat = true + +################################################################################ +# Cost Optimization Options +################################################################################ +# +# Option 1: Standard (enable_nat = true) +# - NAT Gateway: ~$32/mo + $0.045/GB data transfer +# - Recommended for: Production, compliance requirements +# +# Option 2: No NAT (enable_nat = false) +# - Cost: $0/mo for NAT +# - Private subnets can't reach internet +# - Use VPC endpoints for AWS services (S3, DynamoDB, ECR, etc.) +# - Recommended for: Dev/test, serverless-heavy, small accounts +# +# Option 3: Use vpc-lite module instead +# - nat_mode = "none" → $0/mo (VPC endpoints only) +# - nat_mode = "instance" → ~$3/mo (t4g.nano NAT, single-AZ) +# - nat_mode = "gateway" → ~$32/mo (standard) +# +# Example vpc-lite usage: +# module "vpc" { +# source = "../modules/vpc-lite" +# name = "dev-vpc" +# nat_mode = "none" # or "instance" for cheap NAT +# } diff --git a/terraform/03-platform/main.tf b/terraform/03-platform/main.tf new file mode 100644 index 0000000..b00670b --- /dev/null +++ b/terraform/03-platform/main.tf @@ -0,0 +1,432 @@ +################################################################################ +# Layer 03: Platform +# +# Shared platform services for all tenants: +# - ECR repositories for container images +# - CodePipeline/CodeBuild for CI/CD +# - Secrets Manager baseline +# - SSM Parameter Store hierarchy +# +# Depends on: 00-bootstrap, 02-network +################################################################################ + +terraform { + required_version = ">= 1.5" + + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 5.0" + } + } + + backend "s3" { + key = "03-platform/terraform.tfstate" + } +} + +################################################################################ +# Variables +################################################################################ + +variable "region" { + type = string + default = "us-east-1" +} + +variable "state_bucket" { + type = string +} + +variable "project_name" { + type = string + description = "Project name for resource naming" +} + +variable "enable_cicd" { + type = bool + default = true + description = "Enable CI/CD resources (CodeBuild, S3 artifacts)" +} + +variable "ecr_repos" { + type = list(string) + default = ["base", "app"] + description = "List of shared ECR repositories to create" +} + +variable "ecr_image_retention_count" { + type = number + default = 30 + description = "Number of images to retain per repository" +} + +################################################################################ +# Provider +################################################################################ + +provider "aws" { + region = var.region + + default_tags { + tags = { + Layer = "03-platform" + ManagedBy = "terraform" + Project = var.project_name + } + } +} + +################################################################################ +# Data Sources +################################################################################ + +data "terraform_remote_state" "network" { + backend = "s3" + config = { + bucket = var.state_bucket + key = "02-network/terraform.tfstate" + region = var.region + } +} + +data "aws_caller_identity" "current" {} +data "aws_region" "current" {} + +################################################################################ +# ECR Repositories +################################################################################ + +resource "aws_ecr_repository" "shared" { + for_each = toset(var.ecr_repos) + + name = "${var.project_name}/${each.key}" + image_tag_mutability = "MUTABLE" + + image_scanning_configuration { + scan_on_push = true + } + + encryption_configuration { + encryption_type = "AES256" + } + + tags = { + Name = "${var.project_name}-${each.key}" + } +} + +resource "aws_ecr_lifecycle_policy" "shared" { + for_each = aws_ecr_repository.shared + repository = each.value.name + + policy = jsonencode({ + rules = [ + { + rulePriority = 1 + description = "Keep last ${var.ecr_image_retention_count} images" + selection = { + tagStatus = "any" + countType = "imageCountMoreThan" + countNumber = var.ecr_image_retention_count + } + action = { type = "expire" } + } + ] + }) +} + +################################################################################ +# CI/CD - Artifact Bucket +################################################################################ + +resource "aws_s3_bucket" "artifacts" { + count = var.enable_cicd ? 1 : 0 + bucket = "${var.project_name}-cicd-artifacts-${data.aws_caller_identity.current.account_id}" + + tags = { Name = "CI/CD Artifacts" } +} + +resource "aws_s3_bucket_versioning" "artifacts" { + count = var.enable_cicd ? 1 : 0 + bucket = aws_s3_bucket.artifacts[0].id + + versioning_configuration { + status = "Enabled" + } +} + +resource "aws_s3_bucket_lifecycle_configuration" "artifacts" { + count = var.enable_cicd ? 1 : 0 + bucket = aws_s3_bucket.artifacts[0].id + + rule { + id = "cleanup-old-artifacts" + status = "Enabled" + + expiration { + days = 90 + } + + noncurrent_version_expiration { + noncurrent_days = 30 + } + } +} + +resource "aws_s3_bucket_public_access_block" "artifacts" { + count = var.enable_cicd ? 1 : 0 + bucket = aws_s3_bucket.artifacts[0].id + + block_public_acls = true + block_public_policy = true + ignore_public_acls = true + restrict_public_buckets = true +} + +################################################################################ +# CI/CD - CodeBuild Role +################################################################################ + +resource "aws_iam_role" "codebuild" { + count = var.enable_cicd ? 1 : 0 + name = "${var.project_name}-codebuild" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Effect = "Allow" + Action = "sts:AssumeRole" + Principal = { Service = "codebuild.amazonaws.com" } + }] + }) + + tags = { Name = "${var.project_name}-codebuild" } +} + +resource "aws_iam_role_policy" "codebuild" { + count = var.enable_cicd ? 1 : 0 + name = "codebuild-policy" + role = aws_iam_role.codebuild[0].id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Sid = "CloudWatchLogs" + Effect = "Allow" + Action = ["logs:CreateLogGroup", "logs:CreateLogStream", "logs:PutLogEvents"] + Resource = [ + "arn:aws:logs:${data.aws_region.current.name}:${data.aws_caller_identity.current.account_id}:log-group:/aws/codebuild/${var.project_name}-*", + "arn:aws:logs:${data.aws_region.current.name}:${data.aws_caller_identity.current.account_id}:log-group:/aws/codebuild/${var.project_name}-*:*" + ] + }, + { + Sid = "S3Artifacts" + Effect = "Allow" + Action = ["s3:GetObject", "s3:PutObject", "s3:GetObjectVersion"] + Resource = "${aws_s3_bucket.artifacts[0].arn}/*" + }, + { + Sid = "ECRAuth" + Effect = "Allow" + Action = ["ecr:GetAuthorizationToken"] + Resource = "*" + }, + { + Sid = "ECRPush" + Effect = "Allow" + Action = [ + "ecr:BatchCheckLayerAvailability", + "ecr:GetDownloadUrlForLayer", + "ecr:BatchGetImage", + "ecr:PutImage", + "ecr:InitiateLayerUpload", + "ecr:UploadLayerPart", + "ecr:CompleteLayerUpload" + ] + Resource = "arn:aws:ecr:${data.aws_region.current.name}:${data.aws_caller_identity.current.account_id}:repository/${var.project_name}/*" + }, + { + Sid = "SSMParams" + Effect = "Allow" + Action = ["ssm:GetParameter", "ssm:GetParameters", "ssm:GetParametersByPath"] + Resource = "arn:aws:ssm:${data.aws_region.current.name}:${data.aws_caller_identity.current.account_id}:parameter/${var.project_name}/*" + }, + { + Sid = "SecretsManager" + Effect = "Allow" + Action = ["secretsmanager:GetSecretValue"] + Resource = "arn:aws:secretsmanager:${data.aws_region.current.name}:${data.aws_caller_identity.current.account_id}:secret:${var.project_name}/*" + }, + { + Sid = "VPC" + Effect = "Allow" + Action = [ + "ec2:CreateNetworkInterface", + "ec2:DescribeNetworkInterfaces", + "ec2:DeleteNetworkInterface", + "ec2:DescribeSubnets", + "ec2:DescribeSecurityGroups", + "ec2:DescribeVpcs" + ] + Resource = "*" + } + ] + }) +} + +################################################################################ +# CodeBuild - Shared Build Project +################################################################################ + +resource "aws_codebuild_project" "build" { + count = var.enable_cicd ? 1 : 0 + name = "${var.project_name}-build" + description = "Shared build project for ${var.project_name}" + build_timeout = 30 + service_role = aws_iam_role.codebuild[0].arn + + artifacts { + type = "S3" + location = aws_s3_bucket.artifacts[0].bucket + packaging = "ZIP" + } + + environment { + compute_type = "BUILD_GENERAL1_SMALL" + image = "aws/codebuild/amazonlinux2-x86_64-standard:5.0" + type = "LINUX_CONTAINER" + image_pull_credentials_type = "CODEBUILD" + privileged_mode = true # Required for Docker + + environment_variable { + name = "AWS_DEFAULT_REGION" + value = data.aws_region.current.name + } + + environment_variable { + name = "AWS_ACCOUNT_ID" + value = data.aws_caller_identity.current.account_id + } + + environment_variable { + name = "ECR_REGISTRY" + value = "${data.aws_caller_identity.current.account_id}.dkr.ecr.${data.aws_region.current.name}.amazonaws.com" + } + } + + source { + type = "NO_SOURCE" + buildspec = <<-YAML + version: 0.2 + phases: + pre_build: + commands: + - echo "Override this buildspec in your project" + build: + commands: + - echo "Build phase" + post_build: + commands: + - echo "Post-build phase" + YAML + } + + vpc_config { + vpc_id = data.terraform_remote_state.network.outputs.vpc_id + subnets = data.terraform_remote_state.network.outputs.private_subnet_ids + security_group_ids = [aws_security_group.codebuild[0].id] + } + + logs_config { + cloudwatch_logs { + group_name = "/aws/codebuild/${var.project_name}" + stream_name = "build" + } + } + + tags = { Name = "${var.project_name}-build" } +} + +################################################################################ +# CodeBuild Security Group +################################################################################ + +resource "aws_security_group" "codebuild" { + count = var.enable_cicd ? 1 : 0 + name = "${var.project_name}-codebuild" + description = "Security group for CodeBuild" + vpc_id = data.terraform_remote_state.network.outputs.vpc_id + + egress { + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + description = "Allow all outbound" + } + + tags = { Name = "${var.project_name}-codebuild" } +} + +################################################################################ +# SSM Parameter Store - Hierarchy Base +################################################################################ + +resource "aws_ssm_parameter" "platform_config" { + name = "/${var.project_name}/platform/region" + type = "String" + value = data.aws_region.current.name + + tags = { Name = "Platform Region" } +} + +resource "aws_ssm_parameter" "vpc_id" { + name = "/${var.project_name}/platform/vpc_id" + type = "String" + value = data.terraform_remote_state.network.outputs.vpc_id + + tags = { Name = "VPC ID" } +} + +resource "aws_ssm_parameter" "private_subnets" { + name = "/${var.project_name}/platform/private_subnet_ids" + type = "StringList" + value = join(",", data.terraform_remote_state.network.outputs.private_subnet_ids) + + tags = { Name = "Private Subnet IDs" } +} + +################################################################################ +# Outputs +################################################################################ + +output "ecr_repositories" { + value = { + for k, v in aws_ecr_repository.shared : k => { + arn = v.arn + url = v.repository_url + } + } +} + +output "artifacts_bucket" { + value = var.enable_cicd ? aws_s3_bucket.artifacts[0].id : null +} + +output "codebuild_project" { + value = var.enable_cicd ? aws_codebuild_project.build[0].name : null +} + +output "codebuild_role_arn" { + value = var.enable_cicd ? aws_iam_role.codebuild[0].arn : null +} + +output "codebuild_security_group" { + value = var.enable_cicd ? aws_security_group.codebuild[0].id : null +} + +output "ssm_prefix" { + value = "/${var.project_name}" +} diff --git a/terraform/03-platform/terraform.tfvars.example b/terraform/03-platform/terraform.tfvars.example new file mode 100644 index 0000000..3294695 --- /dev/null +++ b/terraform/03-platform/terraform.tfvars.example @@ -0,0 +1,15 @@ +# Example variables for 03-platform layer +# Copy to terraform.tfvars and update values + +state_bucket = "myproject-terraform-state" +project_name = "myproject" +region = "us-east-1" + +# Enable/disable CI/CD resources +enable_cicd = true + +# ECR repositories to create +ecr_repos = ["base", "app", "worker"] + +# Image retention (number of images to keep per repo) +ecr_image_retention_count = 30 diff --git a/terraform/04-tenants/_template/main.tf b/terraform/04-tenants/_template/main.tf new file mode 100644 index 0000000..9bc8756 --- /dev/null +++ b/terraform/04-tenants/_template/main.tf @@ -0,0 +1,466 @@ +################################################################################ +# Layer 04: Tenant - +# +# Creates tenant-specific resources: +# - Security Groups (tenant-scoped, blocks cross-tenant traffic) +# - IAM Roles with ABAC (can only access Tenant=X resources) +# - Budgets with alerts +# +# Usage: +# ./scripts/new-tenant.sh acme +# cd terraform/04-tenants/acme +# # Edit locals below +# terraform init -backend-config=../../00-bootstrap/backend.hcl +# terraform apply -var="state_bucket=YOUR_BUCKET" +################################################################################ + +terraform { + required_version = ">= 1.5" + + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 5.0" + } + } + + backend "s3" { + key = "04-tenants//terraform.tfstate" + } +} + +################################################################################ +# Configuration - UPDATE THESE +################################################################################ + +locals { + # Tenant name (max 20 chars, lowercase, alphanumeric + hyphen) + tenant = "" + + # Environment + env = "prod" # prod, staging, dev + + # Short prefix for resources (tenant-env, max 28 chars total) + prefix = "${local.tenant}-${local.env}" + + # Apps with ports and budgets + apps = { + api = { + port = 8080 + budget = 200 + owner = "team@example.com" + } + web = { + port = 3000 + budget = 100 + owner = "team@example.com" + } + } + + # Budget + budget = 500 + alert_emails = ["ops@example.com"] +} + +################################################################################ +# Variables +################################################################################ + +variable "region" { + type = string + default = "us-east-1" +} + +variable "state_bucket" { + type = string +} + +################################################################################ +# Provider +################################################################################ + +provider "aws" { + region = var.region + + default_tags { + tags = { + Tenant = local.tenant + Environment = local.env + ManagedBy = "terraform" + } + } +} + +################################################################################ +# Data Sources +################################################################################ + +data "terraform_remote_state" "network" { + backend = "s3" + config = { + bucket = var.state_bucket + key = "02-network/terraform.tfstate" + region = var.region + } +} + +data "aws_caller_identity" "current" {} +data "aws_region" "current" {} + +################################################################################ +# Security Group - Base (intra-tenant) +################################################################################ + +resource "aws_security_group" "base" { + name = "${local.prefix}-base" + description = "Base SG for ${local.tenant} - intra-tenant only" + vpc_id = data.terraform_remote_state.network.outputs.vpc_id + + ingress { + description = "Self" + from_port = 0 + to_port = 0 + protocol = "-1" + self = true + } + + egress { + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + } + + tags = { Name = "${local.prefix}-base" } +} + +################################################################################ +# Security Group - Web (public) +################################################################################ + +resource "aws_security_group" "web" { + name = "${local.prefix}-web" + description = "Web SG for ${local.tenant}" + vpc_id = data.terraform_remote_state.network.outputs.vpc_id + + ingress { + description = "HTTPS" + from_port = 443 + to_port = 443 + protocol = "tcp" + cidr_blocks = ["0.0.0.0/0"] + } + + ingress { + description = "HTTP" + from_port = 80 + to_port = 80 + protocol = "tcp" + cidr_blocks = ["0.0.0.0/0"] + } + + egress { + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + } + + tags = { Name = "${local.prefix}-web" } +} + +################################################################################ +# Security Group - Database +################################################################################ + +resource "aws_security_group" "db" { + name = "${local.prefix}-db" + description = "DB SG for ${local.tenant}" + vpc_id = data.terraform_remote_state.network.outputs.vpc_id + + ingress { + description = "PostgreSQL" + from_port = 5432 + to_port = 5432 + protocol = "tcp" + security_groups = [aws_security_group.base.id] + } + + ingress { + description = "MySQL" + from_port = 3306 + to_port = 3306 + protocol = "tcp" + security_groups = [aws_security_group.base.id] + } + + ingress { + description = "Redis" + from_port = 6379 + to_port = 6379 + protocol = "tcp" + security_groups = [aws_security_group.base.id] + } + + tags = { Name = "${local.prefix}-db" } +} + +################################################################################ +# Security Groups - Per App +################################################################################ + +resource "aws_security_group" "app" { + for_each = { for k, v in local.apps : k => v if v.port > 0 } + + name = "${local.prefix}-${each.key}" + description = "SG for ${local.tenant} ${each.key}" + vpc_id = data.terraform_remote_state.network.outputs.vpc_id + + ingress { + description = "App port" + from_port = each.value.port + to_port = each.value.port + protocol = "tcp" + security_groups = [aws_security_group.base.id] + } + + ingress { + description = "Self" + from_port = 0 + to_port = 0 + protocol = "-1" + self = true + } + + egress { + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + } + + tags = { + Name = "${local.prefix}-${each.key}" + App = each.key + } +} + +################################################################################ +# IAM Role - Admin (ABAC) +################################################################################ + +resource "aws_iam_role" "admin" { + name = "${local.prefix}-admin" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Effect = "Allow" + Action = "sts:AssumeRole" + Principal = { AWS = "arn:aws:iam::${data.aws_caller_identity.current.account_id}:root" } + }] + }) + + tags = { Name = "${local.prefix}-admin" } +} + +resource "aws_iam_role_policy" "admin" { + name = "abac" + role = aws_iam_role.admin.id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Sid = "AllowTagged" + Effect = "Allow" + Action = ["ec2:*", "ecs:*", "ecr:*", "lambda:*", "rds:*", "s3:*", "dynamodb:*", "logs:*", "cloudwatch:*", "ssm:*", "secretsmanager:*", "elasticloadbalancing:*"] + Resource = "*" + Condition = { StringEquals = { "aws:ResourceTag/Tenant" = local.tenant } } + }, + { + Sid = "AllowDescribe" + Effect = "Allow" + Action = ["ec2:Describe*", "ecs:Describe*", "ecs:List*", "rds:Describe*", "s3:ListAllMyBuckets", "lambda:List*", "logs:Describe*", "elasticloadbalancing:Describe*"] + Resource = "*" + }, + { + Sid = "AllowCreateTagged" + Effect = "Allow" + Action = ["ec2:RunInstances", "ec2:CreateVolume", "rds:CreateDBInstance", "s3:CreateBucket", "lambda:CreateFunction", "ecs:CreateCluster"] + Resource = "*" + Condition = { StringEquals = { "aws:RequestTag/Tenant" = local.tenant } } + }, + { + Sid = "AllowTagging" + Effect = "Allow" + Action = ["ec2:CreateTags", "rds:AddTagsToResource", "s3:PutBucketTagging", "lambda:TagResource"] + Resource = "*" + Condition = { StringEquals = { "aws:RequestTag/Tenant" = local.tenant } } + } + ] + }) +} + +################################################################################ +# IAM Role - Developer (limited) +################################################################################ + +resource "aws_iam_role" "developer" { + name = "${local.prefix}-dev" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Effect = "Allow" + Action = "sts:AssumeRole" + Principal = { AWS = "arn:aws:iam::${data.aws_caller_identity.current.account_id}:root" } + }] + }) + + tags = { Name = "${local.prefix}-dev" } +} + +resource "aws_iam_role_policy" "developer" { + name = "dev-access" + role = aws_iam_role.developer.id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Sid = "ReadOnly" + Effect = "Allow" + Action = ["ec2:Describe*", "ecs:Describe*", "ecs:List*", "logs:*", "cloudwatch:Get*", "cloudwatch:List*", "ssm:GetParameter*"] + Resource = "*" + }, + { + Sid = "DeployLambda" + Effect = "Allow" + Action = ["lambda:UpdateFunctionCode", "lambda:UpdateFunctionConfiguration"] + Resource = "arn:aws:lambda:${data.aws_region.current.name}:${data.aws_caller_identity.current.account_id}:function:${local.tenant}-*" + } + ] + }) +} + +################################################################################ +# IAM Role - ReadOnly +################################################################################ + +resource "aws_iam_role" "readonly" { + name = "${local.prefix}-ro" + managed_policy_arns = ["arn:aws:iam::aws:policy/ReadOnlyAccess"] + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Effect = "Allow" + Action = "sts:AssumeRole" + Principal = { AWS = "arn:aws:iam::${data.aws_caller_identity.current.account_id}:root" } + }] + }) + + tags = { Name = "${local.prefix}-ro" } +} + +################################################################################ +# Budget - Tenant Total +################################################################################ + +resource "aws_budgets_budget" "tenant" { + name = "${local.prefix}-total" + budget_type = "COST" + limit_amount = tostring(local.budget) + limit_unit = "USD" + time_unit = "MONTHLY" + + cost_filter { + name = "TagKeyValue" + values = ["Tenant$${local.tenant}"] + } + + notification { + comparison_operator = "GREATER_THAN" + notification_type = "ACTUAL" + threshold = 50 + threshold_type = "PERCENTAGE" + subscriber_email_addresses = local.alert_emails + } + + notification { + comparison_operator = "GREATER_THAN" + notification_type = "ACTUAL" + threshold = 80 + threshold_type = "PERCENTAGE" + subscriber_email_addresses = local.alert_emails + } + + notification { + comparison_operator = "GREATER_THAN" + notification_type = "FORECASTED" + threshold = 100 + threshold_type = "PERCENTAGE" + subscriber_email_addresses = local.alert_emails + } +} + +################################################################################ +# Budget - Per App +################################################################################ + +resource "aws_budgets_budget" "app" { + for_each = local.apps + + name = "${local.prefix}-${each.key}" + budget_type = "COST" + limit_amount = tostring(each.value.budget) + limit_unit = "USD" + time_unit = "MONTHLY" + + cost_filter { + name = "TagKeyValue" + values = ["App$${each.key}"] + } + + notification { + comparison_operator = "GREATER_THAN" + notification_type = "ACTUAL" + threshold = 90 + threshold_type = "PERCENTAGE" + subscriber_email_addresses = [each.value.owner] + } +} + +################################################################################ +# Outputs +################################################################################ + +output "tenant" { + value = local.tenant +} + +output "security_groups" { + value = { + base = aws_security_group.base.id + web = aws_security_group.web.id + db = aws_security_group.db.id + apps = { for k, v in aws_security_group.app : k => v.id } + } +} + +output "iam_roles" { + value = { + admin = aws_iam_role.admin.arn + developer = aws_iam_role.developer.arn + readonly = aws_iam_role.readonly.arn + } +} + +output "subnets" { + value = data.terraform_remote_state.network.outputs.private_subnet_ids +} + +output "vpc_id" { + value = data.terraform_remote_state.network.outputs.vpc_id +} diff --git a/terraform/05-workloads/_template/api-gateway/main.tf b/terraform/05-workloads/_template/api-gateway/main.tf new file mode 100644 index 0000000..e2bdd55 --- /dev/null +++ b/terraform/05-workloads/_template/api-gateway/main.tf @@ -0,0 +1,532 @@ +################################################################################ +# Workload: API Gateway REST API +# +# Deploys a REST API with: +# - API Gateway with stages +# - Lambda or HTTP backend integrations +# - Custom domain with ACM +# - WAF integration (optional) +# - CloudWatch logging +# - Usage plans and API keys +# +# Usage: +# Copy this folder to 05-workloads/-/ +# Update locals +# terraform init -backend-config=../../00-bootstrap/backend.hcl +# terraform apply +################################################################################ + +terraform { + required_version = ">= 1.5" + + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 5.0" + } + } + + backend "s3" { + key = "05-workloads/--api/terraform.tfstate" + } +} + +################################################################################ +# Configuration - UPDATE THESE +################################################################################ + +locals { + # Naming + tenant = "" + name = "" + env = "prod" + + api_name = "${local.tenant}-${local.name}-${local.env}" + + # API Type: REST or HTTP + api_type = "REST" # REST for full features, HTTP for simpler/cheaper + + # Custom domain (set to null to skip) + domain_name = null # e.g., "api.example.com" + hosted_zone_id = null # Route53 zone ID + + # WAF (requires waf-alb module deployed) + waf_acl_arn = null + + # Stages + stages = ["prod", "staging"] + + # Throttling defaults + throttle_burst_limit = 100 + throttle_rate_limit = 50 + + # CloudWatch logging + logging_level = "INFO" # OFF, ERROR, INFO + + # Lambda integrations (map of path -> lambda ARN) + lambda_integrations = { + # "GET /users" = "arn:aws:lambda:us-east-1:123456789012:function:get-users" + # "POST /users" = "arn:aws:lambda:us-east-1:123456789012:function:create-user" + # "GET /users/{id}" = "arn:aws:lambda:us-east-1:123456789012:function:get-user" + } + + # HTTP proxy integrations (map of path -> HTTP endpoint) + http_integrations = { + # "GET /health" = "https://backend.example.com/health" + } + + # Mock integrations for static responses + mock_integrations = { + "GET /health" = { + status_code = "200" + response = jsonencode({ status = "healthy" }) + } + } + + # CORS configuration + cors_enabled = true + cors_origins = ["*"] + cors_methods = ["GET", "POST", "PUT", "DELETE", "OPTIONS"] + cors_headers = ["Content-Type", "Authorization", "X-Api-Key"] + + # API Keys and Usage Plans + create_api_key = true + usage_plans = { + basic = { + quota_limit = 1000 + quota_period = "MONTH" + throttle_burst = 10 + throttle_rate = 5 + } + premium = { + quota_limit = 100000 + quota_period = "MONTH" + throttle_burst = 100 + throttle_rate = 50 + } + } +} + +################################################################################ +# Variables +################################################################################ + +variable "region" { + type = string + default = "us-east-1" +} + +variable "state_bucket" { + type = string +} + +################################################################################ +# Provider +################################################################################ + +provider "aws" { + region = var.region + + default_tags { + tags = { + Tenant = local.tenant + App = local.name + Environment = local.env + ManagedBy = "terraform" + } + } +} + +################################################################################ +# Data Sources +################################################################################ + +data "aws_caller_identity" "current" {} +data "aws_region" "current" {} + +################################################################################ +# REST API +################################################################################ + +resource "aws_api_gateway_rest_api" "main" { + name = local.api_name + description = "REST API for ${local.tenant} ${local.name}" + + endpoint_configuration { + types = ["REGIONAL"] + } + + tags = { Name = local.api_name } +} + +################################################################################ +# CloudWatch Logging +################################################################################ + +resource "aws_cloudwatch_log_group" "api" { + name = "/aws/api-gateway/${local.api_name}" + retention_in_days = 30 + + tags = { Name = local.api_name } +} + +resource "aws_api_gateway_account" "main" { + cloudwatch_role_arn = aws_iam_role.api_logging.arn +} + +resource "aws_iam_role" "api_logging" { + name = "${local.api_name}-api-logging" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Effect = "Allow" + Action = "sts:AssumeRole" + Principal = { Service = "apigateway.amazonaws.com" } + }] + }) + + tags = { Name = "${local.api_name}-api-logging" } +} + +resource "aws_iam_role_policy_attachment" "api_logging" { + role = aws_iam_role.api_logging.name + policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonAPIGatewayPushToCloudWatchLogs" +} + +################################################################################ +# Mock Integration - Health Check +################################################################################ + +resource "aws_api_gateway_resource" "health" { + rest_api_id = aws_api_gateway_rest_api.main.id + parent_id = aws_api_gateway_rest_api.main.root_resource_id + path_part = "health" +} + +resource "aws_api_gateway_method" "health_get" { + rest_api_id = aws_api_gateway_rest_api.main.id + resource_id = aws_api_gateway_resource.health.id + http_method = "GET" + authorization = "NONE" +} + +resource "aws_api_gateway_integration" "health_get" { + rest_api_id = aws_api_gateway_rest_api.main.id + resource_id = aws_api_gateway_resource.health.id + http_method = aws_api_gateway_method.health_get.http_method + type = "MOCK" + + request_templates = { + "application/json" = jsonencode({ statusCode = 200 }) + } +} + +resource "aws_api_gateway_method_response" "health_get" { + rest_api_id = aws_api_gateway_rest_api.main.id + resource_id = aws_api_gateway_resource.health.id + http_method = aws_api_gateway_method.health_get.http_method + status_code = "200" + + response_models = { + "application/json" = "Empty" + } + + response_parameters = { + "method.response.header.Access-Control-Allow-Origin" = true + } +} + +resource "aws_api_gateway_integration_response" "health_get" { + rest_api_id = aws_api_gateway_rest_api.main.id + resource_id = aws_api_gateway_resource.health.id + http_method = aws_api_gateway_method.health_get.http_method + status_code = aws_api_gateway_method_response.health_get.status_code + + response_templates = { + "application/json" = jsonencode({ + status = "healthy" + timestamp = "$context.requestTime" + }) + } + + response_parameters = { + "method.response.header.Access-Control-Allow-Origin" = "'*'" + } + + depends_on = [aws_api_gateway_integration.health_get] +} + +################################################################################ +# CORS - OPTIONS method for health +################################################################################ + +resource "aws_api_gateway_method" "health_options" { + count = local.cors_enabled ? 1 : 0 + rest_api_id = aws_api_gateway_rest_api.main.id + resource_id = aws_api_gateway_resource.health.id + http_method = "OPTIONS" + authorization = "NONE" +} + +resource "aws_api_gateway_integration" "health_options" { + count = local.cors_enabled ? 1 : 0 + rest_api_id = aws_api_gateway_rest_api.main.id + resource_id = aws_api_gateway_resource.health.id + http_method = aws_api_gateway_method.health_options[0].http_method + type = "MOCK" + + request_templates = { + "application/json" = jsonencode({ statusCode = 200 }) + } +} + +resource "aws_api_gateway_method_response" "health_options" { + count = local.cors_enabled ? 1 : 0 + rest_api_id = aws_api_gateway_rest_api.main.id + resource_id = aws_api_gateway_resource.health.id + http_method = aws_api_gateway_method.health_options[0].http_method + status_code = "200" + + response_parameters = { + "method.response.header.Access-Control-Allow-Headers" = true + "method.response.header.Access-Control-Allow-Methods" = true + "method.response.header.Access-Control-Allow-Origin" = true + } +} + +resource "aws_api_gateway_integration_response" "health_options" { + count = local.cors_enabled ? 1 : 0 + rest_api_id = aws_api_gateway_rest_api.main.id + resource_id = aws_api_gateway_resource.health.id + http_method = aws_api_gateway_method.health_options[0].http_method + status_code = "200" + + response_parameters = { + "method.response.header.Access-Control-Allow-Headers" = "'${join(",", local.cors_headers)}'" + "method.response.header.Access-Control-Allow-Methods" = "'${join(",", local.cors_methods)}'" + "method.response.header.Access-Control-Allow-Origin" = "'${join(",", local.cors_origins)}'" + } + + depends_on = [aws_api_gateway_integration.health_options] +} + +################################################################################ +# Deployment & Stages +################################################################################ + +resource "aws_api_gateway_deployment" "main" { + rest_api_id = aws_api_gateway_rest_api.main.id + + triggers = { + redeployment = sha1(jsonencode([ + aws_api_gateway_resource.health.id, + aws_api_gateway_method.health_get.id, + aws_api_gateway_integration.health_get.id, + ])) + } + + lifecycle { + create_before_destroy = true + } + + depends_on = [ + aws_api_gateway_integration.health_get, + aws_api_gateway_integration_response.health_get, + ] +} + +resource "aws_api_gateway_stage" "stages" { + for_each = toset(local.stages) + + deployment_id = aws_api_gateway_deployment.main.id + rest_api_id = aws_api_gateway_rest_api.main.id + stage_name = each.value + + access_log_settings { + destination_arn = aws_cloudwatch_log_group.api.arn + format = jsonencode({ + requestId = "$context.requestId" + ip = "$context.identity.sourceIp" + caller = "$context.identity.caller" + user = "$context.identity.user" + requestTime = "$context.requestTime" + httpMethod = "$context.httpMethod" + resourcePath = "$context.resourcePath" + status = "$context.status" + protocol = "$context.protocol" + responseLength = "$context.responseLength" + integrationLatency = "$context.integrationLatency" + }) + } + + tags = { Name = "${local.api_name}-${each.value}" } +} + +resource "aws_api_gateway_method_settings" "stages" { + for_each = toset(local.stages) + + rest_api_id = aws_api_gateway_rest_api.main.id + stage_name = aws_api_gateway_stage.stages[each.value].stage_name + method_path = "*/*" + + settings { + logging_level = local.logging_level + data_trace_enabled = local.logging_level != "OFF" + metrics_enabled = true + + throttling_burst_limit = local.throttle_burst_limit + throttling_rate_limit = local.throttle_rate_limit + } +} + +################################################################################ +# WAF Association (Optional) +################################################################################ + +resource "aws_wafv2_web_acl_association" "api" { + count = local.waf_acl_arn != null ? length(local.stages) : 0 + resource_arn = aws_api_gateway_stage.stages[local.stages[count.index]].arn + web_acl_arn = local.waf_acl_arn +} + +################################################################################ +# Custom Domain (Optional) +################################################################################ + +resource "aws_acm_certificate" "api" { + count = local.domain_name != null ? 1 : 0 + domain_name = local.domain_name + validation_method = "DNS" + + lifecycle { + create_before_destroy = true + } + + tags = { Name = local.domain_name } +} + +resource "aws_route53_record" "cert_validation" { + for_each = local.domain_name != null ? { + for dvo in aws_acm_certificate.api[0].domain_validation_options : dvo.domain_name => { + name = dvo.resource_record_name + record = dvo.resource_record_value + type = dvo.resource_record_type + } + } : {} + + zone_id = local.hosted_zone_id + name = each.value.name + type = each.value.type + records = [each.value.record] + ttl = 60 +} + +resource "aws_acm_certificate_validation" "api" { + count = local.domain_name != null ? 1 : 0 + certificate_arn = aws_acm_certificate.api[0].arn + validation_record_fqdns = [for record in aws_route53_record.cert_validation : record.fqdn] +} + +resource "aws_api_gateway_domain_name" "main" { + count = local.domain_name != null ? 1 : 0 + domain_name = local.domain_name + certificate_arn = aws_acm_certificate_validation.api[0].certificate_arn + + endpoint_configuration { + types = ["REGIONAL"] + } + + tags = { Name = local.domain_name } +} + +resource "aws_api_gateway_base_path_mapping" "main" { + count = local.domain_name != null ? 1 : 0 + api_id = aws_api_gateway_rest_api.main.id + stage_name = aws_api_gateway_stage.stages["prod"].stage_name + domain_name = aws_api_gateway_domain_name.main[0].domain_name +} + +resource "aws_route53_record" "api" { + count = local.domain_name != null ? 1 : 0 + zone_id = local.hosted_zone_id + name = local.domain_name + type = "A" + + alias { + name = aws_api_gateway_domain_name.main[0].regional_domain_name + zone_id = aws_api_gateway_domain_name.main[0].regional_zone_id + evaluate_target_health = false + } +} + +################################################################################ +# API Keys & Usage Plans +################################################################################ + +resource "aws_api_gateway_api_key" "main" { + count = local.create_api_key ? 1 : 0 + name = "${local.api_name}-key" + enabled = true + + tags = { Name = "${local.api_name}-key" } +} + +resource "aws_api_gateway_usage_plan" "plans" { + for_each = local.usage_plans + + name = "${local.api_name}-${each.key}" + + api_stages { + api_id = aws_api_gateway_rest_api.main.id + stage = aws_api_gateway_stage.stages["prod"].stage_name + } + + quota_settings { + limit = each.value.quota_limit + period = each.value.quota_period + } + + throttle_settings { + burst_limit = each.value.throttle_burst + rate_limit = each.value.throttle_rate + } + + tags = { Name = "${local.api_name}-${each.key}" } +} + +resource "aws_api_gateway_usage_plan_key" "main" { + count = local.create_api_key ? 1 : 0 + key_id = aws_api_gateway_api_key.main[0].id + key_type = "API_KEY" + usage_plan_id = aws_api_gateway_usage_plan.plans["basic"].id +} + +################################################################################ +# Outputs +################################################################################ + +output "api_id" { + value = aws_api_gateway_rest_api.main.id +} + +output "api_name" { + value = aws_api_gateway_rest_api.main.name +} + +output "stage_urls" { + value = { for stage in local.stages : stage => aws_api_gateway_stage.stages[stage].invoke_url } +} + +output "custom_domain_url" { + value = local.domain_name != null ? "https://${local.domain_name}" : null +} + +output "api_key" { + value = local.create_api_key ? aws_api_gateway_api_key.main[0].value : null + sensitive = true +} + +output "health_endpoint" { + value = "${aws_api_gateway_stage.stages["prod"].invoke_url}health" +} diff --git a/terraform/05-workloads/_template/aurora-serverless/main.tf b/terraform/05-workloads/_template/aurora-serverless/main.tf new file mode 100644 index 0000000..8c29871 --- /dev/null +++ b/terraform/05-workloads/_template/aurora-serverless/main.tf @@ -0,0 +1,473 @@ +################################################################################ +# Workload: Aurora Serverless v2 +# +# Auto-scaling PostgreSQL/MySQL with: +# - Scale to zero (cost savings for dev) +# - Instant scaling (0.5 ACU increments) +# - Multi-AZ by default +# - IAM authentication +# - Data API (HTTP queries) +# - Secrets Manager integration +# +# Cost: ~$0.12/ACU-hour (scales 0.5-128 ACUs) +# Use cases: Variable workloads, dev/test, bursty traffic +################################################################################ + +terraform { + required_version = ">= 1.5" + + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 5.0" + } + random = { + source = "hashicorp/random" + version = ">= 3.0" + } + } + + backend "s3" { + key = "05-workloads/--aurora/terraform.tfstate" + } +} + +################################################################################ +# Configuration - UPDATE THESE +################################################################################ + +locals { + # Naming + tenant = "" + name = "" + env = "prod" + + cluster_name = "${local.tenant}-${local.name}-${local.env}" + + # Engine + engine = "aurora-postgresql" # aurora-postgresql or aurora-mysql + engine_version = "15.4" # PostgreSQL 15.4 / MySQL 8.0 + + # Serverless v2 capacity + min_capacity = 0.5 # Minimum ACUs (0.5 = scale to near-zero) + max_capacity = 16 # Maximum ACUs (adjust based on needs) + + # For true scale-to-zero (pauses after idle): + # Note: Only available in some regions + enable_pause = false + pause_after_seconds = 300 # 5 minutes idle + + # Database + database_name = replace(local.name, "-", "_") + port = local.engine == "aurora-postgresql" ? 5432 : 3306 + + # Master credentials (stored in Secrets Manager) + master_username = "admin" + + # Network (get from remote state or hardcode) + vpc_id = "" # data.terraform_remote_state.network.outputs.vpc_id + private_subnet_ids = [] # data.terraform_remote_state.network.outputs.private_subnet_ids + + # Features + enable_iam_auth = true + enable_data_api = true # HTTP Data API (for Lambda/serverless) + enable_performance_insights = true + performance_insights_retention = 7 # days (7 = free tier) + + # Backup + backup_retention_period = 7 + preferred_backup_window = "03:00-04:00" + + # Maintenance + preferred_maintenance_window = "sun:04:00-sun:05:00" + auto_minor_version_upgrade = true + + # Deletion protection (enable for production) + deletion_protection = local.env == "prod" + skip_final_snapshot = local.env != "prod" +} + +################################################################################ +# Variables +################################################################################ + +variable "region" { + type = string + default = "us-east-1" +} + +variable "state_bucket" { + type = string +} + +################################################################################ +# Provider +################################################################################ + +provider "aws" { + region = var.region + + default_tags { + tags = { + Tenant = local.tenant + App = local.name + Environment = local.env + ManagedBy = "terraform" + } + } +} + +################################################################################ +# Data Sources +################################################################################ + +data "aws_caller_identity" "current" {} +data "aws_region" "current" {} + +################################################################################ +# Random Password +################################################################################ + +resource "random_password" "master" { + length = 32 + special = false # Aurora has special char restrictions +} + +################################################################################ +# Secrets Manager +################################################################################ + +resource "aws_secretsmanager_secret" "db" { + name = "${local.tenant}/${local.env}/${local.name}/aurora" + description = "Aurora Serverless credentials for ${local.cluster_name}" + + tags = { Name = "${local.cluster_name}-credentials" } +} + +resource "aws_secretsmanager_secret_version" "db" { + secret_id = aws_secretsmanager_secret.db.id + secret_string = jsonencode({ + username = local.master_username + password = random_password.master.result + engine = local.engine + host = aws_rds_cluster.main.endpoint + port = local.port + dbname = local.database_name + dbClusterIdentifier = aws_rds_cluster.main.id + }) +} + +################################################################################ +# Security Group +################################################################################ + +resource "aws_security_group" "aurora" { + count = length(local.vpc_id) > 0 ? 1 : 0 + name = "${local.cluster_name}-aurora" + vpc_id = local.vpc_id + + ingress { + description = "Database from VPC" + from_port = local.port + to_port = local.port + protocol = "tcp" + cidr_blocks = ["10.0.0.0/8"] # Adjust to your VPC CIDR + } + + egress { + description = "All outbound" + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + } + + tags = { Name = "${local.cluster_name}-aurora" } +} + +################################################################################ +# DB Subnet Group +################################################################################ + +resource "aws_db_subnet_group" "main" { + count = length(local.private_subnet_ids) > 0 ? 1 : 0 + name = local.cluster_name + subnet_ids = local.private_subnet_ids + + tags = { Name = local.cluster_name } +} + +################################################################################ +# Aurora Serverless v2 Cluster +################################################################################ + +resource "aws_rds_cluster" "main" { + cluster_identifier = local.cluster_name + engine = local.engine + engine_mode = "provisioned" # Required for Serverless v2 + engine_version = local.engine_version + + database_name = local.database_name + master_username = local.master_username + master_password = random_password.master.result + port = local.port + + # Serverless v2 scaling + serverlessv2_scaling_configuration { + min_capacity = local.min_capacity + max_capacity = local.max_capacity + } + + # Network + db_subnet_group_name = length(aws_db_subnet_group.main) > 0 ? aws_db_subnet_group.main[0].name : null + vpc_security_group_ids = length(aws_security_group.aurora) > 0 ? [aws_security_group.aurora[0].id] : [] + + # Storage + storage_encrypted = true + kms_key_id = null # Uses AWS managed key + + # Features + enable_http_endpoint = local.enable_data_api + iam_database_authentication_enabled = local.enable_iam_auth + + # Backup + backup_retention_period = local.backup_retention_period + preferred_backup_window = local.preferred_backup_window + copy_tags_to_snapshot = true + skip_final_snapshot = local.skip_final_snapshot + final_snapshot_identifier = local.skip_final_snapshot ? null : "${local.cluster_name}-final" + + # Maintenance + preferred_maintenance_window = local.preferred_maintenance_window + apply_immediately = false + + # Protection + deletion_protection = local.deletion_protection + + tags = { Name = local.cluster_name } + + lifecycle { + ignore_changes = [ + master_password, # Managed in Secrets Manager + ] + } +} + +################################################################################ +# Aurora Serverless v2 Instance +################################################################################ + +resource "aws_rds_cluster_instance" "main" { + count = 1 # Add more for read replicas + + identifier = "${local.cluster_name}-${count.index + 1}" + cluster_identifier = aws_rds_cluster.main.id + instance_class = "db.serverless" # Required for Serverless v2 + engine = local.engine + engine_version = local.engine_version + + # Performance Insights + performance_insights_enabled = local.enable_performance_insights + performance_insights_retention_period = local.enable_performance_insights ? local.performance_insights_retention : null + + # Maintenance + auto_minor_version_upgrade = local.auto_minor_version_upgrade + + tags = { Name = "${local.cluster_name}-${count.index + 1}" } +} + +################################################################################ +# IAM Role for IAM Authentication +################################################################################ + +resource "aws_iam_policy" "db_connect" { + count = local.enable_iam_auth ? 1 : 0 + name = "${local.cluster_name}-db-connect" + description = "IAM authentication to ${local.cluster_name}" + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Sid = "AllowDBConnect" + Effect = "Allow" + Action = "rds-db:connect" + Resource = "arn:aws:rds-db:${data.aws_region.current.name}:${data.aws_caller_identity.current.account_id}:dbuser:${aws_rds_cluster.main.cluster_resource_id}/*" + } + ] + }) + + tags = { Name = "${local.cluster_name}-db-connect" } +} + +################################################################################ +# Data API Access Policy +################################################################################ + +resource "aws_iam_policy" "data_api" { + count = local.enable_data_api ? 1 : 0 + name = "${local.cluster_name}-data-api" + description = "Data API access to ${local.cluster_name}" + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Sid = "ExecuteStatement" + Effect = "Allow" + Action = [ + "rds-data:ExecuteStatement", + "rds-data:BatchExecuteStatement", + "rds-data:BeginTransaction", + "rds-data:CommitTransaction", + "rds-data:RollbackTransaction" + ] + Resource = aws_rds_cluster.main.arn + }, + { + Sid = "GetSecret" + Effect = "Allow" + Action = "secretsmanager:GetSecretValue" + Resource = aws_secretsmanager_secret.db.arn + } + ] + }) + + tags = { Name = "${local.cluster_name}-data-api" } +} + +################################################################################ +# CloudWatch Alarms +################################################################################ + +resource "aws_cloudwatch_metric_alarm" "cpu" { + alarm_name = "${local.cluster_name}-cpu-high" + comparison_operator = "GreaterThanThreshold" + evaluation_periods = 2 + metric_name = "CPUUtilization" + namespace = "AWS/RDS" + period = 300 + statistic = "Average" + threshold = 80 + alarm_description = "Aurora CPU > 80%" + + dimensions = { + DBClusterIdentifier = aws_rds_cluster.main.id + } + + tags = { Name = "${local.cluster_name}-cpu-high" } +} + +resource "aws_cloudwatch_metric_alarm" "connections" { + alarm_name = "${local.cluster_name}-connections-high" + comparison_operator = "GreaterThanThreshold" + evaluation_periods = 2 + metric_name = "DatabaseConnections" + namespace = "AWS/RDS" + period = 300 + statistic = "Average" + threshold = 100 + alarm_description = "Aurora connections > 100" + + dimensions = { + DBClusterIdentifier = aws_rds_cluster.main.id + } + + tags = { Name = "${local.cluster_name}-connections-high" } +} + +resource "aws_cloudwatch_metric_alarm" "capacity" { + alarm_name = "${local.cluster_name}-acu-high" + comparison_operator = "GreaterThanThreshold" + evaluation_periods = 3 + metric_name = "ServerlessDatabaseCapacity" + namespace = "AWS/RDS" + period = 300 + statistic = "Average" + threshold = local.max_capacity * 0.8 + alarm_description = "Aurora ACU > 80% of max" + + dimensions = { + DBClusterIdentifier = aws_rds_cluster.main.id + } + + tags = { Name = "${local.cluster_name}-acu-high" } +} + +################################################################################ +# Outputs +################################################################################ + +output "cluster_endpoint" { + value = aws_rds_cluster.main.endpoint + description = "Writer endpoint" +} + +output "reader_endpoint" { + value = aws_rds_cluster.main.reader_endpoint + description = "Reader endpoint" +} + +output "cluster_arn" { + value = aws_rds_cluster.main.arn + description = "Cluster ARN" +} + +output "cluster_id" { + value = aws_rds_cluster.main.id + description = "Cluster identifier" +} + +output "port" { + value = local.port + description = "Database port" +} + +output "database_name" { + value = local.database_name + description = "Database name" +} + +output "secret_arn" { + value = aws_secretsmanager_secret.db.arn + description = "Secrets Manager ARN" +} + +output "iam_auth_policy_arn" { + value = length(aws_iam_policy.db_connect) > 0 ? aws_iam_policy.db_connect[0].arn : null + description = "IAM policy for database authentication" +} + +output "data_api_policy_arn" { + value = length(aws_iam_policy.data_api) > 0 ? aws_iam_policy.data_api[0].arn : null + description = "IAM policy for Data API access" +} + +output "connection_string" { + value = "${local.engine == "aurora-postgresql" ? "postgresql" : "mysql"}://${local.master_username}:****@${aws_rds_cluster.main.endpoint}:${local.port}/${local.database_name}" + description = "Connection string template (password in Secrets Manager)" + sensitive = false +} + +output "data_api_example" { + value = local.enable_data_api ? <<-EOF + aws rds-data execute-statement \ + --resource-arn '${aws_rds_cluster.main.arn}' \ + --secret-arn '${aws_secretsmanager_secret.db.arn}' \ + --database '${local.database_name}' \ + --sql 'SELECT NOW()' + EOF + : null + description = "Data API example command" +} + +output "cost_estimate" { + value = { + acu_hour = "$0.12/ACU-hour" + min_idle = "$${local.min_capacity * 0.12 * 24 * 30}/month (${local.min_capacity} ACU 24/7)" + storage = "$0.10/GB-month" + io = "$0.20/million requests" + data_api = "$0.35/million Data API requests" + } + description = "Cost breakdown" +} diff --git a/terraform/05-workloads/_template/cognito-auth/main.tf b/terraform/05-workloads/_template/cognito-auth/main.tf new file mode 100644 index 0000000..5f22e89 --- /dev/null +++ b/terraform/05-workloads/_template/cognito-auth/main.tf @@ -0,0 +1,501 @@ +################################################################################ +# Workload: Cognito User Pool +# +# User authentication infrastructure: +# - User Pool with customizable password policy +# - App clients (web, mobile, machine-to-machine) +# - Identity Pool for AWS credential federation +# - Social/SAML/OIDC identity providers +# - Custom domain +# - Lambda triggers (pre/post auth, migration) +# +# Use cases: Web/mobile auth, B2C apps, admin portals +################################################################################ + +terraform { + required_version = ">= 1.5" + + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 5.0" + } + } + + backend "s3" { + key = "05-workloads/--auth/terraform.tfstate" + } +} + +################################################################################ +# Configuration - UPDATE THESE +################################################################################ + +locals { + # Naming + tenant = "" + name = "" + env = "prod" + + pool_name = "${local.tenant}-${local.name}-${local.env}" + + # Email configuration + email_sending_account = "COGNITO_DEFAULT" # COGNITO_DEFAULT or DEVELOPER + ses_email_from = null # Required if DEVELOPER + + # Password policy + password_minimum_length = 12 + password_require_lowercase = true + password_require_numbers = true + password_require_symbols = true + password_require_uppercase = true + temporary_password_validity_days = 7 + + # MFA + mfa_configuration = "OPTIONAL" # OFF, ON, OPTIONAL + mfa_methods = ["SOFTWARE_TOKEN_MFA"] # SOFTWARE_TOKEN_MFA, SMS_MFA + + # Account recovery + recovery_mechanisms = [ + { name = "verified_email", priority = 1 }, + { name = "verified_phone_number", priority = 2 } + ] + + # User attributes + auto_verified_attributes = ["email"] + username_attributes = ["email"] # email, phone_number + alias_attributes = [] # email, phone_number, preferred_username + + # Custom attributes + custom_attributes = { + # "tenant_id" = { + # type = "String" + # mutable = false + # min_length = 1 + # max_length = 50 + # } + } + + # App clients + app_clients = { + web = { + generate_secret = false + explicit_auth_flows = ["ALLOW_USER_SRP_AUTH", "ALLOW_REFRESH_TOKEN_AUTH"] + supported_identity_providers = ["COGNITO"] + callback_urls = ["https://example.com/callback"] + logout_urls = ["https://example.com/logout"] + allowed_oauth_flows = ["code"] + allowed_oauth_scopes = ["email", "openid", "profile"] + allowed_oauth_flows_user_pool_client = true + access_token_validity = 60 # minutes + id_token_validity = 60 + refresh_token_validity = 30 # days + } + mobile = { + generate_secret = false + explicit_auth_flows = ["ALLOW_USER_SRP_AUTH", "ALLOW_REFRESH_TOKEN_AUTH"] + supported_identity_providers = ["COGNITO"] + callback_urls = ["myapp://callback"] + logout_urls = ["myapp://logout"] + allowed_oauth_flows = ["code"] + allowed_oauth_scopes = ["email", "openid", "profile"] + allowed_oauth_flows_user_pool_client = true + access_token_validity = 60 + id_token_validity = 60 + refresh_token_validity = 30 + } + # m2m = { + # generate_secret = true + # explicit_auth_flows = ["ALLOW_ADMIN_USER_PASSWORD_AUTH"] + # supported_identity_providers = ["COGNITO"] + # allowed_oauth_flows = ["client_credentials"] + # allowed_oauth_scopes = ["api/read", "api/write"] + # allowed_oauth_flows_user_pool_client = true + # } + } + + # Custom domain (requires ACM cert in us-east-1 for CloudFront) + custom_domain = null # e.g., "auth.example.com" + custom_domain_cert = null # ACM certificate ARN + hosted_zone_id = null + + # Identity Pool (for AWS credential federation) + enable_identity_pool = false + + # Lambda triggers + lambda_triggers = { + # pre_sign_up = "arn:aws:lambda:..." + # post_confirmation = "arn:aws:lambda:..." + # pre_authentication = "arn:aws:lambda:..." + # post_authentication = "arn:aws:lambda:..." + # pre_token_generation = "arn:aws:lambda:..." + # user_migration = "arn:aws:lambda:..." + # custom_message = "arn:aws:lambda:..." + } + + # Social identity providers + social_providers = { + # google = { + # client_id = "..." + # client_secret = "..." + # scopes = ["email", "profile", "openid"] + # } + # facebook = { + # client_id = "..." + # client_secret = "..." + # scopes = ["email", "public_profile"] + # } + } +} + +################################################################################ +# Variables +################################################################################ + +variable "region" { + type = string + default = "us-east-1" +} + +variable "state_bucket" { + type = string +} + +################################################################################ +# Provider +################################################################################ + +provider "aws" { + region = var.region + + default_tags { + tags = { + Tenant = local.tenant + App = local.name + Environment = local.env + ManagedBy = "terraform" + } + } +} + +################################################################################ +# Data Sources +################################################################################ + +data "aws_caller_identity" "current" {} +data "aws_region" "current" {} + +################################################################################ +# Cognito User Pool +################################################################################ + +resource "aws_cognito_user_pool" "main" { + name = local.pool_name + + # Username configuration + username_attributes = local.username_attributes + alias_attributes = length(local.alias_attributes) > 0 ? local.alias_attributes : null + auto_verified_attributes = local.auto_verified_attributes + + # Password policy + password_policy { + minimum_length = local.password_minimum_length + require_lowercase = local.password_require_lowercase + require_numbers = local.password_require_numbers + require_symbols = local.password_require_symbols + require_uppercase = local.password_require_uppercase + temporary_password_validity_days = local.temporary_password_validity_days + } + + # MFA + mfa_configuration = local.mfa_configuration + + dynamic "software_token_mfa_configuration" { + for_each = contains(local.mfa_methods, "SOFTWARE_TOKEN_MFA") && local.mfa_configuration != "OFF" ? [1] : [] + content { + enabled = true + } + } + + # Account recovery + account_recovery_setting { + dynamic "recovery_mechanism" { + for_each = local.recovery_mechanisms + content { + name = recovery_mechanism.value.name + priority = recovery_mechanism.value.priority + } + } + } + + # Email configuration + email_configuration { + email_sending_account = local.email_sending_account + source_arn = local.email_sending_account == "DEVELOPER" ? local.ses_email_from : null + } + + # User attribute verification + user_attribute_update_settings { + attributes_require_verification_before_update = ["email"] + } + + # Admin create user config + admin_create_user_config { + allow_admin_create_user_only = false + + invite_message_template { + email_subject = "Your ${local.pool_name} account" + email_message = "Your username is {username} and temporary password is {####}" + sms_message = "Your username is {username} and temporary password is {####}" + } + } + + # Verification message + verification_message_template { + default_email_option = "CONFIRM_WITH_CODE" + email_subject = "Verify your email for ${local.pool_name}" + email_message = "Your verification code is {####}" + } + + # Schema (custom attributes) + dynamic "schema" { + for_each = local.custom_attributes + content { + name = schema.key + attribute_data_type = schema.value.type + mutable = schema.value.mutable + required = false + developer_only_attribute = false + + dynamic "string_attribute_constraints" { + for_each = schema.value.type == "String" ? [1] : [] + content { + min_length = lookup(schema.value, "min_length", 0) + max_length = lookup(schema.value, "max_length", 2048) + } + } + + dynamic "number_attribute_constraints" { + for_each = schema.value.type == "Number" ? [1] : [] + content { + min_value = lookup(schema.value, "min_value", null) + max_value = lookup(schema.value, "max_value", null) + } + } + } + } + + # Lambda triggers + lambda_config { + pre_sign_up = lookup(local.lambda_triggers, "pre_sign_up", null) + post_confirmation = lookup(local.lambda_triggers, "post_confirmation", null) + pre_authentication = lookup(local.lambda_triggers, "pre_authentication", null) + post_authentication = lookup(local.lambda_triggers, "post_authentication", null) + pre_token_generation = lookup(local.lambda_triggers, "pre_token_generation", null) + user_migration = lookup(local.lambda_triggers, "user_migration", null) + custom_message = lookup(local.lambda_triggers, "custom_message", null) + } + + tags = { Name = local.pool_name } +} + +################################################################################ +# User Pool Domain +################################################################################ + +resource "aws_cognito_user_pool_domain" "main" { + domain = local.custom_domain != null ? local.custom_domain : local.pool_name + user_pool_id = aws_cognito_user_pool.main.id + certificate_arn = local.custom_domain_cert +} + +# Route53 record for custom domain +resource "aws_route53_record" "cognito" { + count = local.custom_domain != null ? 1 : 0 + zone_id = local.hosted_zone_id + name = local.custom_domain + type = "A" + + alias { + name = aws_cognito_user_pool_domain.main.cloudfront_distribution_arn + zone_id = "Z2FDTNDATAQYW2" # CloudFront zone ID + evaluate_target_health = false + } +} + +################################################################################ +# App Clients +################################################################################ + +resource "aws_cognito_user_pool_client" "clients" { + for_each = local.app_clients + + name = "${local.pool_name}-${each.key}" + user_pool_id = aws_cognito_user_pool.main.id + + generate_secret = each.value.generate_secret + explicit_auth_flows = each.value.explicit_auth_flows + supported_identity_providers = each.value.supported_identity_providers + callback_urls = lookup(each.value, "callback_urls", null) + logout_urls = lookup(each.value, "logout_urls", null) + allowed_oauth_flows = lookup(each.value, "allowed_oauth_flows", null) + allowed_oauth_scopes = lookup(each.value, "allowed_oauth_scopes", null) + allowed_oauth_flows_user_pool_client = lookup(each.value, "allowed_oauth_flows_user_pool_client", false) + + access_token_validity = lookup(each.value, "access_token_validity", 60) + id_token_validity = lookup(each.value, "id_token_validity", 60) + refresh_token_validity = lookup(each.value, "refresh_token_validity", 30) + + token_validity_units { + access_token = "minutes" + id_token = "minutes" + refresh_token = "days" + } + + prevent_user_existence_errors = "ENABLED" + enable_token_revocation = true +} + +################################################################################ +# Social Identity Providers +################################################################################ + +resource "aws_cognito_identity_provider" "google" { + count = contains(keys(local.social_providers), "google") ? 1 : 0 + user_pool_id = aws_cognito_user_pool.main.id + provider_name = "Google" + provider_type = "Google" + + provider_details = { + client_id = local.social_providers.google.client_id + client_secret = local.social_providers.google.client_secret + authorize_scopes = join(" ", local.social_providers.google.scopes) + } + + attribute_mapping = { + email = "email" + username = "sub" + } +} + +resource "aws_cognito_identity_provider" "facebook" { + count = contains(keys(local.social_providers), "facebook") ? 1 : 0 + user_pool_id = aws_cognito_user_pool.main.id + provider_name = "Facebook" + provider_type = "Facebook" + + provider_details = { + client_id = local.social_providers.facebook.client_id + client_secret = local.social_providers.facebook.client_secret + authorize_scopes = join(",", local.social_providers.facebook.scopes) + } + + attribute_mapping = { + email = "email" + username = "id" + } +} + +################################################################################ +# Identity Pool (Optional) +################################################################################ + +resource "aws_cognito_identity_pool" "main" { + count = local.enable_identity_pool ? 1 : 0 + identity_pool_name = replace(local.pool_name, "-", "_") + allow_unauthenticated_identities = false + + cognito_identity_providers { + client_id = aws_cognito_user_pool_client.clients["web"].id + provider_name = aws_cognito_user_pool.main.endpoint + server_side_token_check = true + } + + tags = { Name = local.pool_name } +} + +resource "aws_iam_role" "authenticated" { + count = local.enable_identity_pool ? 1 : 0 + name = "${local.pool_name}-authenticated" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Effect = "Allow" + Principal = { + Federated = "cognito-identity.amazonaws.com" + } + Action = "sts:AssumeRoleWithWebIdentity" + Condition = { + StringEquals = { + "cognito-identity.amazonaws.com:aud" = aws_cognito_identity_pool.main[0].id + } + "ForAnyValue:StringLike" = { + "cognito-identity.amazonaws.com:amr" = "authenticated" + } + } + }] + }) + + tags = { Name = "${local.pool_name}-authenticated" } +} + +resource "aws_iam_role_policy" "authenticated" { + count = local.enable_identity_pool ? 1 : 0 + name = "authenticated-policy" + role = aws_iam_role.authenticated[0].id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Effect = "Allow" + Action = [ + "mobileanalytics:PutEvents", + "cognito-sync:*", + "cognito-identity:*" + ] + Resource = "*" + }] + }) +} + +resource "aws_cognito_identity_pool_roles_attachment" "main" { + count = local.enable_identity_pool ? 1 : 0 + identity_pool_id = aws_cognito_identity_pool.main[0].id + + roles = { + authenticated = aws_iam_role.authenticated[0].arn + } +} + +################################################################################ +# Outputs +################################################################################ + +output "user_pool_id" { + value = aws_cognito_user_pool.main.id +} + +output "user_pool_arn" { + value = aws_cognito_user_pool.main.arn +} + +output "user_pool_endpoint" { + value = aws_cognito_user_pool.main.endpoint +} + +output "user_pool_domain" { + value = local.custom_domain != null ? "https://${local.custom_domain}" : "https://${aws_cognito_user_pool_domain.main.domain}.auth.${data.aws_region.current.name}.amazoncognito.com" +} + +output "client_ids" { + value = { for k, v in aws_cognito_user_pool_client.clients : k => v.id } +} + +output "identity_pool_id" { + value = local.enable_identity_pool ? aws_cognito_identity_pool.main[0].id : null +} + +output "hosted_ui_url" { + value = "${local.custom_domain != null ? "https://${local.custom_domain}" : "https://${aws_cognito_user_pool_domain.main.domain}.auth.${data.aws_region.current.name}.amazoncognito.com"}/login?client_id=${aws_cognito_user_pool_client.clients["web"].id}&response_type=code&redirect_uri=${urlencode(local.app_clients.web.callback_urls[0])}" +} diff --git a/terraform/05-workloads/_template/dynamodb-table/main.tf b/terraform/05-workloads/_template/dynamodb-table/main.tf new file mode 100644 index 0000000..14db472 --- /dev/null +++ b/terraform/05-workloads/_template/dynamodb-table/main.tf @@ -0,0 +1,439 @@ +################################################################################ +# Workload: DynamoDB Table +# +# Deploys a NoSQL database table: +# - On-demand or provisioned capacity +# - Encryption at rest with KMS +# - Point-in-time recovery +# - TTL support +# - Global Secondary Indexes +# - Streams for event-driven patterns +# +# Usage: +# Copy this folder to 05-workloads/-/ +# Update locals and variables +# terraform init -backend-config=../../00-bootstrap/backend.hcl +# terraform apply +################################################################################ + +terraform { + required_version = ">= 1.5" + + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 5.0" + } + } + + backend "s3" { + key = "05-workloads/--table/terraform.tfstate" + } +} + +################################################################################ +# Configuration - UPDATE THESE +################################################################################ + +locals { + # Naming + tenant = "" + name = "" + env = "prod" + + table_name = "${local.tenant}-${local.name}-${local.env}" + + # Capacity mode: "PAY_PER_REQUEST" (on-demand) or "PROVISIONED" + billing_mode = "PAY_PER_REQUEST" + + # Provisioned capacity (only used if billing_mode = "PROVISIONED") + read_capacity = 5 + write_capacity = 5 + + # Auto-scaling for provisioned mode + enable_autoscaling = local.billing_mode == "PROVISIONED" + autoscaling_min_read = 5 + autoscaling_max_read = 100 + autoscaling_min_write = 5 + autoscaling_max_write = 100 + autoscaling_target_utilization = 70 + + # Primary key + hash_key = "pk" # Partition key + hash_key_type = "S" # S = String, N = Number, B = Binary + range_key = "sk" # Sort key (optional, set to null to disable) + range_key_type = "S" + + # TTL (set to null to disable) + ttl_attribute = "ttl" + + # Streams (set to null to disable) + # Options: KEYS_ONLY, NEW_IMAGE, OLD_IMAGE, NEW_AND_OLD_IMAGES + stream_view_type = null + + # Point-in-time recovery + point_in_time_recovery = true + + # Global Secondary Indexes (GSI) + global_secondary_indexes = [ + # { + # name = "gsi1" + # hash_key = "gsi1pk" + # range_key = "gsi1sk" + # projection_type = "ALL" # ALL, KEYS_ONLY, or INCLUDE + # non_key_attributes = [] # Only for INCLUDE + # } + ] + + # Local Secondary Indexes (LSI) - must be defined at table creation + local_secondary_indexes = [ + # { + # name = "lsi1" + # range_key = "lsi1sk" + # projection_type = "ALL" + # non_key_attributes = [] + # } + ] + + # Table class: STANDARD or STANDARD_INFREQUENT_ACCESS + table_class = "STANDARD" +} + +################################################################################ +# Variables +################################################################################ + +variable "region" { + type = string + default = "us-east-1" +} + +variable "state_bucket" { + type = string +} + +################################################################################ +# Provider +################################################################################ + +provider "aws" { + region = var.region + + default_tags { + tags = { + Tenant = local.tenant + App = local.name + Environment = local.env + ManagedBy = "terraform" + } + } +} + +################################################################################ +# Data Sources +################################################################################ + +data "aws_caller_identity" "current" {} +data "aws_region" "current" {} + +################################################################################ +# KMS Key +################################################################################ + +resource "aws_kms_key" "table" { + description = "KMS key for ${local.table_name} DynamoDB encryption" + deletion_window_in_days = 7 + enable_key_rotation = true + + tags = { Name = "${local.table_name}-dynamodb" } +} + +resource "aws_kms_alias" "table" { + name = "alias/${local.table_name}-dynamodb" + target_key_id = aws_kms_key.table.key_id +} + +################################################################################ +# DynamoDB Table +################################################################################ + +resource "aws_dynamodb_table" "main" { + name = local.table_name + billing_mode = local.billing_mode + table_class = local.table_class + + # Capacity (only for PROVISIONED) + read_capacity = local.billing_mode == "PROVISIONED" ? local.read_capacity : null + write_capacity = local.billing_mode == "PROVISIONED" ? local.write_capacity : null + + # Primary key + hash_key = local.hash_key + range_key = local.range_key + + # Key schema + attribute { + name = local.hash_key + type = local.hash_key_type + } + + dynamic "attribute" { + for_each = local.range_key != null ? [1] : [] + content { + name = local.range_key + type = local.range_key_type + } + } + + # GSI attributes + dynamic "attribute" { + for_each = local.global_secondary_indexes + content { + name = attribute.value.hash_key + type = "S" + } + } + + dynamic "attribute" { + for_each = [for gsi in local.global_secondary_indexes : gsi if gsi.range_key != null] + content { + name = attribute.value.range_key + type = "S" + } + } + + # LSI attributes + dynamic "attribute" { + for_each = local.local_secondary_indexes + content { + name = attribute.value.range_key + type = "S" + } + } + + # Global Secondary Indexes + dynamic "global_secondary_index" { + for_each = local.global_secondary_indexes + content { + name = global_secondary_index.value.name + hash_key = global_secondary_index.value.hash_key + range_key = lookup(global_secondary_index.value, "range_key", null) + projection_type = global_secondary_index.value.projection_type + non_key_attributes = global_secondary_index.value.projection_type == "INCLUDE" ? global_secondary_index.value.non_key_attributes : null + + # Capacity for provisioned mode + read_capacity = local.billing_mode == "PROVISIONED" ? local.read_capacity : null + write_capacity = local.billing_mode == "PROVISIONED" ? local.write_capacity : null + } + } + + # Local Secondary Indexes + dynamic "local_secondary_index" { + for_each = local.local_secondary_indexes + content { + name = local_secondary_index.value.name + range_key = local_secondary_index.value.range_key + projection_type = local_secondary_index.value.projection_type + non_key_attributes = local_secondary_index.value.projection_type == "INCLUDE" ? local_secondary_index.value.non_key_attributes : null + } + } + + # TTL + dynamic "ttl" { + for_each = local.ttl_attribute != null ? [1] : [] + content { + attribute_name = local.ttl_attribute + enabled = true + } + } + + # Streams + stream_enabled = local.stream_view_type != null + stream_view_type = local.stream_view_type + + # Encryption + server_side_encryption { + enabled = true + kms_key_arn = aws_kms_key.table.arn + } + + # Point-in-time recovery + point_in_time_recovery { + enabled = local.point_in_time_recovery + } + + # Deletion protection for prod + deletion_protection_enabled = local.env == "prod" + + tags = { + Name = local.table_name + Backup = "true" + } + + lifecycle { + prevent_destroy = false # Set to true for production + } +} + +################################################################################ +# Auto Scaling (Provisioned Mode Only) +################################################################################ + +resource "aws_appautoscaling_target" "read" { + count = local.enable_autoscaling ? 1 : 0 + max_capacity = local.autoscaling_max_read + min_capacity = local.autoscaling_min_read + resource_id = "table/${aws_dynamodb_table.main.name}" + scalable_dimension = "dynamodb:table:ReadCapacityUnits" + service_namespace = "dynamodb" +} + +resource "aws_appautoscaling_policy" "read" { + count = local.enable_autoscaling ? 1 : 0 + name = "${local.table_name}-read-autoscaling" + policy_type = "TargetTrackingScaling" + resource_id = aws_appautoscaling_target.read[0].resource_id + scalable_dimension = aws_appautoscaling_target.read[0].scalable_dimension + service_namespace = aws_appautoscaling_target.read[0].service_namespace + + target_tracking_scaling_policy_configuration { + predefined_metric_specification { + predefined_metric_type = "DynamoDBReadCapacityUtilization" + } + target_value = local.autoscaling_target_utilization + } +} + +resource "aws_appautoscaling_target" "write" { + count = local.enable_autoscaling ? 1 : 0 + max_capacity = local.autoscaling_max_write + min_capacity = local.autoscaling_min_write + resource_id = "table/${aws_dynamodb_table.main.name}" + scalable_dimension = "dynamodb:table:WriteCapacityUnits" + service_namespace = "dynamodb" +} + +resource "aws_appautoscaling_policy" "write" { + count = local.enable_autoscaling ? 1 : 0 + name = "${local.table_name}-write-autoscaling" + policy_type = "TargetTrackingScaling" + resource_id = aws_appautoscaling_target.write[0].resource_id + scalable_dimension = aws_appautoscaling_target.write[0].scalable_dimension + service_namespace = aws_appautoscaling_target.write[0].service_namespace + + target_tracking_scaling_policy_configuration { + predefined_metric_specification { + predefined_metric_type = "DynamoDBWriteCapacityUtilization" + } + target_value = local.autoscaling_target_utilization + } +} + +################################################################################ +# CloudWatch Alarms +################################################################################ + +resource "aws_cloudwatch_metric_alarm" "throttled_requests" { + alarm_name = "${local.table_name}-throttled-requests" + comparison_operator = "GreaterThanThreshold" + evaluation_periods = 2 + metric_name = "ThrottledRequests" + namespace = "AWS/DynamoDB" + period = 300 + statistic = "Sum" + threshold = 0 + alarm_description = "DynamoDB throttled requests detected" + + dimensions = { + TableName = aws_dynamodb_table.main.name + } + + tags = { Name = "${local.table_name}-throttled" } +} + +resource "aws_cloudwatch_metric_alarm" "system_errors" { + alarm_name = "${local.table_name}-system-errors" + comparison_operator = "GreaterThanThreshold" + evaluation_periods = 2 + metric_name = "SystemErrors" + namespace = "AWS/DynamoDB" + period = 300 + statistic = "Sum" + threshold = 0 + alarm_description = "DynamoDB system errors detected" + + dimensions = { + TableName = aws_dynamodb_table.main.name + } + + tags = { Name = "${local.table_name}-errors" } +} + +################################################################################ +# IAM Policy Document (for application access) +################################################################################ + +data "aws_iam_policy_document" "table_access" { + statement { + sid = "AllowTableOperations" + effect = "Allow" + + actions = [ + "dynamodb:BatchGetItem", + "dynamodb:BatchWriteItem", + "dynamodb:DeleteItem", + "dynamodb:GetItem", + "dynamodb:PutItem", + "dynamodb:Query", + "dynamodb:Scan", + "dynamodb:UpdateItem", + "dynamodb:DescribeTable", + ] + + resources = [ + aws_dynamodb_table.main.arn, + "${aws_dynamodb_table.main.arn}/index/*", + ] + } + + statement { + sid = "AllowKMSDecrypt" + effect = "Allow" + + actions = [ + "kms:Decrypt", + "kms:Encrypt", + "kms:GenerateDataKey", + ] + + resources = [aws_kms_key.table.arn] + } +} + +################################################################################ +# Outputs +################################################################################ + +output "table_name" { + value = aws_dynamodb_table.main.name +} + +output "table_arn" { + value = aws_dynamodb_table.main.arn +} + +output "table_id" { + value = aws_dynamodb_table.main.id +} + +output "stream_arn" { + value = aws_dynamodb_table.main.stream_arn +} + +output "kms_key_arn" { + value = aws_kms_key.table.arn +} + +output "access_policy_json" { + value = data.aws_iam_policy_document.table_access.json + description = "IAM policy document for application access to this table" +} diff --git a/terraform/05-workloads/_template/ecr-repository/main.tf b/terraform/05-workloads/_template/ecr-repository/main.tf new file mode 100644 index 0000000..a3384df --- /dev/null +++ b/terraform/05-workloads/_template/ecr-repository/main.tf @@ -0,0 +1,400 @@ +################################################################################ +# Workload: ECR Repository +# +# Container registry with: +# - Image scanning on push +# - Lifecycle policies (cleanup old images) +# - Cross-account access +# - Replication to other regions +# - Immutable tags (optional) +# +# Use cases: Docker images, Lambda container images +################################################################################ + +terraform { + required_version = ">= 1.5" + + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 5.0" + } + } + + backend "s3" { + key = "05-workloads/--ecr/terraform.tfstate" + } +} + +################################################################################ +# Configuration - UPDATE THESE +################################################################################ + +locals { + # Naming + tenant = "" + name = "" + env = "prod" + + # Multiple repositories can be created + repositories = { + api = { + description = "API service container" + } + worker = { + description = "Background worker container" + } + # Add more as needed + } + + # Image scanning + scan_on_push = true + + # Tag immutability (prevents overwriting tags) + image_tag_mutability = "MUTABLE" # MUTABLE or IMMUTABLE + + # Encryption + encryption_type = "AES256" # AES256 or KMS + kms_key_arn = null # Set if using KMS + + # Lifecycle policy - cleanup old images + lifecycle_policy = { + # Keep last N tagged images + keep_tagged_count = 30 + + # Delete untagged images older than N days + untagged_expiry_days = 7 + + # Keep images with these tag prefixes forever + keep_tag_prefixes = ["release-", "v"] + } + + # Cross-account access (account IDs that can pull) + pull_access_accounts = [ + # "123456789012", # Dev account + # "234567890123", # Staging account + ] + + # Cross-account push access + push_access_accounts = [ + # "345678901234", # CI/CD account + ] + + # IAM principals with pull access + pull_access_principals = [ + # "arn:aws:iam::123456789012:role/ecs-task-role", + ] + + # Replication to other regions + replication_regions = [ + # "us-west-2", + # "eu-west-1", + ] +} + +################################################################################ +# Variables +################################################################################ + +variable "region" { + type = string + default = "us-east-1" +} + +variable "state_bucket" { + type = string +} + +################################################################################ +# Provider +################################################################################ + +provider "aws" { + region = var.region + + default_tags { + tags = { + Tenant = local.tenant + App = local.name + Environment = local.env + ManagedBy = "terraform" + } + } +} + +################################################################################ +# Data Sources +################################################################################ + +data "aws_caller_identity" "current" {} +data "aws_region" "current" {} + +################################################################################ +# ECR Repositories +################################################################################ + +resource "aws_ecr_repository" "repos" { + for_each = local.repositories + + name = "${local.tenant}/${local.name}/${each.key}" + image_tag_mutability = local.image_tag_mutability + + image_scanning_configuration { + scan_on_push = local.scan_on_push + } + + encryption_configuration { + encryption_type = local.encryption_type + kms_key = local.kms_key_arn + } + + tags = { + Name = "${local.tenant}/${local.name}/${each.key}" + Description = each.value.description + } +} + +################################################################################ +# Lifecycle Policies +################################################################################ + +resource "aws_ecr_lifecycle_policy" "repos" { + for_each = local.repositories + repository = aws_ecr_repository.repos[each.key].name + + policy = jsonencode({ + rules = [ + # Keep tagged images with specific prefixes + { + rulePriority = 1 + description = "Keep release images" + selection = { + tagStatus = "tagged" + tagPrefixList = local.lifecycle_policy.keep_tag_prefixes + countType = "imageCountMoreThan" + countNumber = 9999 + } + action = { + type = "expire" + } + }, + # Keep last N tagged images + { + rulePriority = 10 + description = "Keep last ${local.lifecycle_policy.keep_tagged_count} tagged images" + selection = { + tagStatus = "tagged" + tagPrefixList = [""] + countType = "imageCountMoreThan" + countNumber = local.lifecycle_policy.keep_tagged_count + } + action = { + type = "expire" + } + }, + # Delete old untagged images + { + rulePriority = 20 + description = "Delete untagged images older than ${local.lifecycle_policy.untagged_expiry_days} days" + selection = { + tagStatus = "untagged" + countType = "sinceImagePushed" + countUnit = "days" + countNumber = local.lifecycle_policy.untagged_expiry_days + } + action = { + type = "expire" + } + } + ] + }) +} + +################################################################################ +# Repository Policies (Cross-Account Access) +################################################################################ + +resource "aws_ecr_repository_policy" "repos" { + for_each = length(local.pull_access_accounts) > 0 || length(local.push_access_accounts) > 0 || length(local.pull_access_principals) > 0 ? local.repositories : {} + repository = aws_ecr_repository.repos[each.key].name + + policy = jsonencode({ + Version = "2012-10-17" + Statement = concat( + # Cross-account pull access + length(local.pull_access_accounts) > 0 ? [{ + Sid = "CrossAccountPull" + Effect = "Allow" + Principal = { + AWS = [for acct in local.pull_access_accounts : "arn:aws:iam::${acct}:root"] + } + Action = [ + "ecr:GetDownloadUrlForLayer", + "ecr:BatchGetImage", + "ecr:BatchCheckLayerAvailability" + ] + }] : [], + + # Cross-account push access + length(local.push_access_accounts) > 0 ? [{ + Sid = "CrossAccountPush" + Effect = "Allow" + Principal = { + AWS = [for acct in local.push_access_accounts : "arn:aws:iam::${acct}:root"] + } + Action = [ + "ecr:GetDownloadUrlForLayer", + "ecr:BatchGetImage", + "ecr:BatchCheckLayerAvailability", + "ecr:PutImage", + "ecr:InitiateLayerUpload", + "ecr:UploadLayerPart", + "ecr:CompleteLayerUpload" + ] + }] : [], + + # Principal-based pull access + length(local.pull_access_principals) > 0 ? [{ + Sid = "PrincipalPull" + Effect = "Allow" + Principal = { + AWS = local.pull_access_principals + } + Action = [ + "ecr:GetDownloadUrlForLayer", + "ecr:BatchGetImage", + "ecr:BatchCheckLayerAvailability" + ] + }] : [] + ) + }) +} + +################################################################################ +# Replication Configuration +################################################################################ + +resource "aws_ecr_replication_configuration" "main" { + count = length(local.replication_regions) > 0 ? 1 : 0 + + replication_configuration { + rule { + dynamic "destination" { + for_each = local.replication_regions + content { + region = destination.value + registry_id = data.aws_caller_identity.current.account_id + } + } + + repository_filter { + filter = "${local.tenant}/${local.name}/" + filter_type = "PREFIX_MATCH" + } + } + } +} + +################################################################################ +# IAM Policy for CI/CD +################################################################################ + +resource "aws_iam_policy" "push" { + name = "${local.tenant}-${local.name}-ecr-push" + description = "Push access to ${local.tenant}/${local.name} ECR repositories" + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Sid = "GetAuthToken" + Effect = "Allow" + Action = "ecr:GetAuthorizationToken" + Resource = "*" + }, + { + Sid = "PushImages" + Effect = "Allow" + Action = [ + "ecr:BatchCheckLayerAvailability", + "ecr:GetDownloadUrlForLayer", + "ecr:BatchGetImage", + "ecr:PutImage", + "ecr:InitiateLayerUpload", + "ecr:UploadLayerPart", + "ecr:CompleteLayerUpload" + ] + Resource = [for repo in aws_ecr_repository.repos : repo.arn] + } + ] + }) + + tags = { Name = "${local.tenant}-${local.name}-ecr-push" } +} + +resource "aws_iam_policy" "pull" { + name = "${local.tenant}-${local.name}-ecr-pull" + description = "Pull access to ${local.tenant}/${local.name} ECR repositories" + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Sid = "GetAuthToken" + Effect = "Allow" + Action = "ecr:GetAuthorizationToken" + Resource = "*" + }, + { + Sid = "PullImages" + Effect = "Allow" + Action = [ + "ecr:BatchCheckLayerAvailability", + "ecr:GetDownloadUrlForLayer", + "ecr:BatchGetImage" + ] + Resource = [for repo in aws_ecr_repository.repos : repo.arn] + } + ] + }) + + tags = { Name = "${local.tenant}-${local.name}-ecr-pull" } +} + +################################################################################ +# Outputs +################################################################################ + +output "repository_urls" { + value = { for k, v in aws_ecr_repository.repos : k => v.repository_url } + description = "Repository URLs for docker push/pull" +} + +output "repository_arns" { + value = { for k, v in aws_ecr_repository.repos : k => v.arn } + description = "Repository ARNs" +} + +output "push_policy_arn" { + value = aws_iam_policy.push.arn + description = "IAM policy ARN for push access" +} + +output "pull_policy_arn" { + value = aws_iam_policy.pull.arn + description = "IAM policy ARN for pull access" +} + +output "docker_login_command" { + value = "aws ecr get-login-password --region ${data.aws_region.current.name} | docker login --username AWS --password-stdin ${data.aws_caller_identity.current.account_id}.dkr.ecr.${data.aws_region.current.name}.amazonaws.com" + description = "Command to authenticate Docker with ECR" +} + +output "push_commands" { + value = { for k, v in aws_ecr_repository.repos : k => <<-EOF + docker build -t ${v.repository_url}:latest . + docker push ${v.repository_url}:latest + EOF + } + description = "Docker build and push commands for each repository" +} diff --git a/terraform/05-workloads/_template/ecs-fargate/main.tf b/terraform/05-workloads/_template/ecs-fargate/main.tf new file mode 100644 index 0000000..991a818 --- /dev/null +++ b/terraform/05-workloads/_template/ecs-fargate/main.tf @@ -0,0 +1,701 @@ +################################################################################ +# Workload: ECS Fargate Service +# +# Container service with: +# - Fargate (serverless containers) +# - Auto-scaling +# - ALB integration +# - Service discovery +# - Secrets/SSM integration +# - CloudWatch logging +# - X-Ray tracing +# +# Use cases: Web services, APIs, microservices +################################################################################ + +terraform { + required_version = ">= 1.5" + + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 5.0" + } + } + + backend "s3" { + key = "05-workloads/--ecs/terraform.tfstate" + } +} + +################################################################################ +# Configuration - UPDATE THESE +################################################################################ + +locals { + # Naming + tenant = "" + name = "" + env = "prod" + + prefix = "${local.tenant}-${local.name}-${local.env}" + + # Container configuration + container = { + image = "nginx:latest" # Update to your ECR image + port = 80 + protocol = "HTTP" + + # Resources (Fargate valid combinations) + # CPU: 256, 512, 1024, 2048, 4096 + # Memory depends on CPU + cpu = 256 + memory = 512 + + # Health check + health_check_path = "/health" + health_check_interval = 30 + + # Environment variables + environment = { + LOG_LEVEL = "info" + NODE_ENV = local.env + } + + # Secrets from SSM Parameter Store + secrets_ssm = { + # DATABASE_URL = "/${local.tenant}/${local.env}/${local.name}/database/url" + } + + # Secrets from Secrets Manager + secrets_sm = { + # API_KEY = "myapp/api-key" + } + } + + # Service configuration + service = { + desired_count = 2 + min_count = 1 + max_count = 10 + + # Deployment + deployment_max_percent = 200 + deployment_min_healthy_percent = 100 + + # Enable execute command (for debugging) + enable_execute_command = true + } + + # Network (get from remote state or hardcode) + vpc_id = "" # data.terraform_remote_state.network.outputs.vpc_id + private_subnet_ids = [] # data.terraform_remote_state.network.outputs.private_subnet_ids + public_subnet_ids = [] # data.terraform_remote_state.network.outputs.public_subnet_ids + + # Load balancer + alb = { + enabled = true + internal = false + certificate_arn = "" # ACM certificate ARN for HTTPS + health_check_path = local.container.health_check_path + } + + # Auto-scaling + autoscaling = { + enabled = true + + # CPU-based scaling + cpu_target = 70 + + # Request count scaling (if ALB) + requests_target = 1000 # requests per target per minute + + # Scale-in cooldown + scale_in_cooldown = 300 + scale_out_cooldown = 60 + } + + # Service discovery (Cloud Map) + service_discovery = { + enabled = false + namespace_id = "" # Cloud Map namespace ID + dns_ttl = 10 + } + + # Logging + log_retention_days = 30 + + # X-Ray tracing + enable_xray = false +} + +################################################################################ +# Variables +################################################################################ + +variable "region" { + type = string + default = "us-east-1" +} + +variable "state_bucket" { + type = string +} + +################################################################################ +# Provider +################################################################################ + +provider "aws" { + region = var.region + + default_tags { + tags = { + Tenant = local.tenant + App = local.name + Environment = local.env + ManagedBy = "terraform" + } + } +} + +################################################################################ +# Data Sources +################################################################################ + +data "aws_caller_identity" "current" {} +data "aws_region" "current" {} + +################################################################################ +# ECS Cluster +################################################################################ + +resource "aws_ecs_cluster" "main" { + name = local.prefix + + setting { + name = "containerInsights" + value = "enabled" + } + + tags = { Name = local.prefix } +} + +resource "aws_ecs_cluster_capacity_providers" "main" { + cluster_name = aws_ecs_cluster.main.name + + capacity_providers = ["FARGATE", "FARGATE_SPOT"] + + default_capacity_provider_strategy { + base = 1 + weight = 100 + capacity_provider = "FARGATE" + } +} + +################################################################################ +# CloudWatch Logs +################################################################################ + +resource "aws_cloudwatch_log_group" "app" { + name = "/ecs/${local.prefix}" + retention_in_days = local.log_retention_days + + tags = { Name = local.prefix } +} + +################################################################################ +# IAM Roles +################################################################################ + +# Task execution role (ECS agent) +resource "aws_iam_role" "execution" { + name = "${local.prefix}-execution" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Effect = "Allow" + Action = "sts:AssumeRole" + Principal = { Service = "ecs-tasks.amazonaws.com" } + }] + }) + + tags = { Name = "${local.prefix}-execution" } +} + +resource "aws_iam_role_policy_attachment" "execution" { + role = aws_iam_role.execution.name + policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy" +} + +resource "aws_iam_role_policy" "execution_secrets" { + name = "secrets-access" + role = aws_iam_role.execution.id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Sid = "SSMParameters" + Effect = "Allow" + Action = [ + "ssm:GetParameters", + "ssm:GetParameter" + ] + Resource = "arn:aws:ssm:${data.aws_region.current.name}:${data.aws_caller_identity.current.account_id}:parameter/${local.tenant}/*" + }, + { + Sid = "SecretsManager" + Effect = "Allow" + Action = "secretsmanager:GetSecretValue" + Resource = "arn:aws:secretsmanager:${data.aws_region.current.name}:${data.aws_caller_identity.current.account_id}:secret:${local.tenant}/*" + }, + { + Sid = "KMSDecrypt" + Effect = "Allow" + Action = "kms:Decrypt" + Resource = "*" + } + ] + }) +} + +# Task role (application) +resource "aws_iam_role" "task" { + name = "${local.prefix}-task" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Effect = "Allow" + Action = "sts:AssumeRole" + Principal = { Service = "ecs-tasks.amazonaws.com" } + }] + }) + + tags = { Name = "${local.prefix}-task" } +} + +# Allow ECS exec +resource "aws_iam_role_policy" "task_exec" { + count = local.service.enable_execute_command ? 1 : 0 + name = "ecs-exec" + role = aws_iam_role.task.id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Effect = "Allow" + Action = [ + "ssmmessages:CreateControlChannel", + "ssmmessages:CreateDataChannel", + "ssmmessages:OpenControlChannel", + "ssmmessages:OpenDataChannel" + ] + Resource = "*" + }] + }) +} + +# X-Ray tracing +resource "aws_iam_role_policy" "task_xray" { + count = local.enable_xray ? 1 : 0 + name = "xray" + role = aws_iam_role.task.id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Effect = "Allow" + Action = [ + "xray:PutTraceSegments", + "xray:PutTelemetryRecords", + "xray:GetSamplingRules", + "xray:GetSamplingTargets", + "xray:GetSamplingStatisticSummaries" + ] + Resource = "*" + }] + }) +} + +################################################################################ +# Security Groups +################################################################################ + +resource "aws_security_group" "service" { + count = length(local.vpc_id) > 0 ? 1 : 0 + name = "${local.prefix}-service" + vpc_id = local.vpc_id + + ingress { + description = "From ALB" + from_port = local.container.port + to_port = local.container.port + protocol = "tcp" + security_groups = local.alb.enabled ? [aws_security_group.alb[0].id] : [] + cidr_blocks = local.alb.enabled ? [] : ["0.0.0.0/0"] + } + + egress { + description = "All outbound" + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + } + + tags = { Name = "${local.prefix}-service" } +} + +resource "aws_security_group" "alb" { + count = local.alb.enabled && length(local.vpc_id) > 0 ? 1 : 0 + name = "${local.prefix}-alb" + vpc_id = local.vpc_id + + ingress { + description = "HTTPS" + from_port = 443 + to_port = 443 + protocol = "tcp" + cidr_blocks = ["0.0.0.0/0"] + } + + ingress { + description = "HTTP redirect" + from_port = 80 + to_port = 80 + protocol = "tcp" + cidr_blocks = ["0.0.0.0/0"] + } + + egress { + description = "To service" + from_port = local.container.port + to_port = local.container.port + protocol = "tcp" + cidr_blocks = ["0.0.0.0/0"] + } + + tags = { Name = "${local.prefix}-alb" } +} + +################################################################################ +# Application Load Balancer +################################################################################ + +resource "aws_lb" "main" { + count = local.alb.enabled && length(local.public_subnet_ids) > 0 ? 1 : 0 + name = local.prefix + internal = local.alb.internal + load_balancer_type = "application" + security_groups = [aws_security_group.alb[0].id] + subnets = local.alb.internal ? local.private_subnet_ids : local.public_subnet_ids + + enable_deletion_protection = local.env == "prod" + + tags = { Name = local.prefix } +} + +resource "aws_lb_target_group" "main" { + count = local.alb.enabled && length(local.vpc_id) > 0 ? 1 : 0 + name = local.prefix + port = local.container.port + protocol = "HTTP" + vpc_id = local.vpc_id + target_type = "ip" + + health_check { + enabled = true + healthy_threshold = 2 + unhealthy_threshold = 3 + timeout = 5 + interval = local.container.health_check_interval + path = local.alb.health_check_path + matcher = "200-299" + } + + tags = { Name = local.prefix } +} + +resource "aws_lb_listener" "https" { + count = local.alb.enabled && length(local.alb.certificate_arn) > 0 && length(local.public_subnet_ids) > 0 ? 1 : 0 + load_balancer_arn = aws_lb.main[0].arn + port = 443 + protocol = "HTTPS" + ssl_policy = "ELBSecurityPolicy-TLS13-1-2-2021-06" + certificate_arn = local.alb.certificate_arn + + default_action { + type = "forward" + target_group_arn = aws_lb_target_group.main[0].arn + } +} + +resource "aws_lb_listener" "http_redirect" { + count = local.alb.enabled && length(local.public_subnet_ids) > 0 ? 1 : 0 + load_balancer_arn = aws_lb.main[0].arn + port = 80 + protocol = "HTTP" + + default_action { + type = length(local.alb.certificate_arn) > 0 ? "redirect" : "forward" + + dynamic "redirect" { + for_each = length(local.alb.certificate_arn) > 0 ? [1] : [] + content { + port = "443" + protocol = "HTTPS" + status_code = "HTTP_301" + } + } + + target_group_arn = length(local.alb.certificate_arn) > 0 ? null : aws_lb_target_group.main[0].arn + } +} + +################################################################################ +# Task Definition +################################################################################ + +resource "aws_ecs_task_definition" "main" { + family = local.prefix + network_mode = "awsvpc" + requires_compatibilities = ["FARGATE"] + cpu = local.container.cpu + memory = local.container.memory + execution_role_arn = aws_iam_role.execution.arn + task_role_arn = aws_iam_role.task.arn + + container_definitions = jsonencode(concat( + [{ + name = "app" + image = local.container.image + essential = true + + portMappings = [{ + containerPort = local.container.port + protocol = "tcp" + }] + + environment = [ + for k, v in local.container.environment : { name = k, value = v } + ] + + secrets = concat( + [for k, v in local.container.secrets_ssm : { + name = k + valueFrom = "arn:aws:ssm:${data.aws_region.current.name}:${data.aws_caller_identity.current.account_id}:parameter${v}" + }], + [for k, v in local.container.secrets_sm : { + name = k + valueFrom = "arn:aws:secretsmanager:${data.aws_region.current.name}:${data.aws_caller_identity.current.account_id}:secret:${v}" + }] + ) + + logConfiguration = { + logDriver = "awslogs" + options = { + "awslogs-group" = aws_cloudwatch_log_group.app.name + "awslogs-region" = data.aws_region.current.name + "awslogs-stream-prefix" = "app" + } + } + + healthCheck = { + command = ["CMD-SHELL", "curl -f http://localhost:${local.container.port}${local.container.health_check_path} || exit 1"] + interval = 30 + timeout = 5 + retries = 3 + startPeriod = 60 + } + }], + local.enable_xray ? [{ + name = "xray-daemon" + image = "amazon/aws-xray-daemon:latest" + essential = false + cpu = 32 + memory = 256 + portMappings = [{ + containerPort = 2000 + protocol = "udp" + }] + logConfiguration = { + logDriver = "awslogs" + options = { + "awslogs-group" = aws_cloudwatch_log_group.app.name + "awslogs-region" = data.aws_region.current.name + "awslogs-stream-prefix" = "xray" + } + } + }] : [] + )) + + tags = { Name = local.prefix } +} + +################################################################################ +# ECS Service +################################################################################ + +resource "aws_ecs_service" "main" { + count = length(local.vpc_id) > 0 && length(local.private_subnet_ids) > 0 ? 1 : 0 + name = local.prefix + cluster = aws_ecs_cluster.main.id + task_definition = aws_ecs_task_definition.main.arn + desired_count = local.service.desired_count + launch_type = "FARGATE" + + deployment_maximum_percent = local.service.deployment_max_percent + deployment_minimum_healthy_percent = local.service.deployment_min_healthy_percent + enable_execute_command = local.service.enable_execute_command + + network_configuration { + subnets = local.private_subnet_ids + security_groups = [aws_security_group.service[0].id] + assign_public_ip = false + } + + dynamic "load_balancer" { + for_each = local.alb.enabled ? [1] : [] + content { + target_group_arn = aws_lb_target_group.main[0].arn + container_name = "app" + container_port = local.container.port + } + } + + dynamic "service_registries" { + for_each = local.service_discovery.enabled ? [1] : [] + content { + registry_arn = aws_service_discovery_service.main[0].arn + } + } + + tags = { Name = local.prefix } + + lifecycle { + ignore_changes = [desired_count] + } +} + +################################################################################ +# Auto Scaling +################################################################################ + +resource "aws_appautoscaling_target" "main" { + count = local.autoscaling.enabled && length(local.vpc_id) > 0 ? 1 : 0 + max_capacity = local.service.max_count + min_capacity = local.service.min_count + resource_id = "service/${aws_ecs_cluster.main.name}/${aws_ecs_service.main[0].name}" + scalable_dimension = "ecs:service:DesiredCount" + service_namespace = "ecs" +} + +resource "aws_appautoscaling_policy" "cpu" { + count = local.autoscaling.enabled && length(local.vpc_id) > 0 ? 1 : 0 + name = "${local.prefix}-cpu" + policy_type = "TargetTrackingScaling" + resource_id = aws_appautoscaling_target.main[0].resource_id + scalable_dimension = aws_appautoscaling_target.main[0].scalable_dimension + service_namespace = aws_appautoscaling_target.main[0].service_namespace + + target_tracking_scaling_policy_configuration { + predefined_metric_specification { + predefined_metric_type = "ECSServiceAverageCPUUtilization" + } + target_value = local.autoscaling.cpu_target + scale_in_cooldown = local.autoscaling.scale_in_cooldown + scale_out_cooldown = local.autoscaling.scale_out_cooldown + } +} + +resource "aws_appautoscaling_policy" "requests" { + count = local.autoscaling.enabled && local.alb.enabled && length(local.vpc_id) > 0 ? 1 : 0 + name = "${local.prefix}-requests" + policy_type = "TargetTrackingScaling" + resource_id = aws_appautoscaling_target.main[0].resource_id + scalable_dimension = aws_appautoscaling_target.main[0].scalable_dimension + service_namespace = aws_appautoscaling_target.main[0].service_namespace + + target_tracking_scaling_policy_configuration { + predefined_metric_specification { + predefined_metric_type = "ALBRequestCountPerTarget" + resource_label = "${aws_lb.main[0].arn_suffix}/${aws_lb_target_group.main[0].arn_suffix}" + } + target_value = local.autoscaling.requests_target + scale_in_cooldown = local.autoscaling.scale_in_cooldown + scale_out_cooldown = local.autoscaling.scale_out_cooldown + } +} + +################################################################################ +# Service Discovery +################################################################################ + +resource "aws_service_discovery_service" "main" { + count = local.service_discovery.enabled ? 1 : 0 + name = local.name + + dns_config { + namespace_id = local.service_discovery.namespace_id + + dns_records { + ttl = local.service_discovery.dns_ttl + type = "A" + } + + routing_policy = "MULTIVALUE" + } + + health_check_custom_config { + failure_threshold = 1 + } + + tags = { Name = local.prefix } +} + +################################################################################ +# Outputs +################################################################################ + +output "cluster_name" { + value = aws_ecs_cluster.main.name + description = "ECS cluster name" +} + +output "service_name" { + value = length(aws_ecs_service.main) > 0 ? aws_ecs_service.main[0].name : null + description = "ECS service name" +} + +output "alb_dns_name" { + value = length(aws_lb.main) > 0 ? aws_lb.main[0].dns_name : null + description = "ALB DNS name" +} + +output "alb_zone_id" { + value = length(aws_lb.main) > 0 ? aws_lb.main[0].zone_id : null + description = "ALB hosted zone ID (for Route53 alias)" +} + +output "task_definition_arn" { + value = aws_ecs_task_definition.main.arn + description = "Task definition ARN" +} + +output "log_group" { + value = aws_cloudwatch_log_group.app.name + description = "CloudWatch log group" +} + +output "exec_command" { + value = length(aws_ecs_service.main) > 0 ? "aws ecs execute-command --cluster ${aws_ecs_cluster.main.name} --task --container app --interactive --command '/bin/sh'" : null + description = "ECS exec command for debugging" +} + +output "update_command" { + value = length(aws_ecs_service.main) > 0 ? "aws ecs update-service --cluster ${aws_ecs_cluster.main.name} --service ${aws_ecs_service.main[0].name} --force-new-deployment" : null + description = "Force new deployment command" +} diff --git a/terraform/05-workloads/_template/ecs-service/main.tf b/terraform/05-workloads/_template/ecs-service/main.tf new file mode 100644 index 0000000..8c9bd79 --- /dev/null +++ b/terraform/05-workloads/_template/ecs-service/main.tf @@ -0,0 +1,560 @@ +################################################################################ +# Workload: ECS Fargate Service +# +# Deploys a containerized application on ECS Fargate: +# - ECS Service with Fargate launch type +# - Application Load Balancer (optional) +# - Auto-scaling based on CPU/Memory +# - CloudWatch logging +# +# Usage: +# Copy this folder to 05-workloads/-/ +# Update locals and variables +# terraform init -backend-config=../../00-bootstrap/backend.hcl +# terraform apply +################################################################################ + +terraform { + required_version = ">= 1.5" + + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 5.0" + } + } + + backend "s3" { + key = "05-workloads/-/terraform.tfstate" + } +} + +################################################################################ +# Configuration - UPDATE THESE +################################################################################ + +locals { + # Naming + tenant = "" + app = "" + env = "prod" # prod, staging, dev + name = "${local.tenant}-${local.app}-${local.env}" + + # Short name for resources with strict limits (ALB: 32 chars, TG: 32 chars) + # Uses first 10 chars of tenant + first 10 of app + env suffix + short_name = "${substr(local.tenant, 0, min(10, length(local.tenant)))}-${substr(local.app, 0, min(10, length(local.app)))}-${substr(local.env, 0, 4)}" + + # Container config + container_image = "nginx:latest" # Replace with your ECR image + container_port = 8080 + cpu = 256 # 0.25 vCPU + memory = 512 # MB + + # Scaling + desired_count = 2 + min_count = 1 + max_count = 10 + + # Load balancer + enable_alb = true + health_check_path = "/health" + + # Environment variables (non-sensitive) + environment = { + APP_ENV = local.env + LOG_LEVEL = "info" + } + + # Secrets from SSM/Secrets Manager (ARNs) + secrets = { + # DATABASE_URL = "arn:aws:secretsmanager:us-east-1:123456789:secret:mydb-xxx" + } +} + +################################################################################ +# Variables +################################################################################ + +variable "region" { + type = string + default = "us-east-1" +} + +variable "state_bucket" { + type = string +} + +################################################################################ +# Provider +################################################################################ + +provider "aws" { + region = var.region + + default_tags { + tags = { + Tenant = local.tenant + App = local.app + Environment = local.env + ManagedBy = "terraform" + } + } +} + +################################################################################ +# Data Sources +################################################################################ + +data "terraform_remote_state" "network" { + backend = "s3" + config = { + bucket = var.state_bucket + key = "02-network/terraform.tfstate" + region = var.region + } +} + +data "terraform_remote_state" "tenant" { + backend = "s3" + config = { + bucket = var.state_bucket + key = "04-tenants/${local.tenant}/terraform.tfstate" + region = var.region + } +} + +data "terraform_remote_state" "bootstrap" { + backend = "s3" + config = { + bucket = var.state_bucket + key = "00-bootstrap/terraform.tfstate" + region = var.region + } +} + +data "aws_caller_identity" "current" {} +data "aws_region" "current" {} + +################################################################################ +# ECS Cluster +################################################################################ + +resource "aws_ecs_cluster" "main" { + name = local.name + + setting { + name = "containerInsights" + value = "enabled" + } + + tags = { Name = local.name } +} + +resource "aws_ecs_cluster_capacity_providers" "main" { + cluster_name = aws_ecs_cluster.main.name + + capacity_providers = ["FARGATE", "FARGATE_SPOT"] + + default_capacity_provider_strategy { + base = 1 + weight = 100 + capacity_provider = "FARGATE" + } +} + +################################################################################ +# CloudWatch Log Group +################################################################################ + +resource "aws_cloudwatch_log_group" "main" { + name = "/ecs/${local.name}" + retention_in_days = 30 + + tags = { Name = local.name } +} + +################################################################################ +# IAM - Task Execution Role +################################################################################ + +resource "aws_iam_role" "execution" { + name = "${local.name}-execution" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Effect = "Allow" + Action = "sts:AssumeRole" + Principal = { Service = "ecs-tasks.amazonaws.com" } + }] + }) + + tags = { Name = "${local.name}-execution" } +} + +resource "aws_iam_role_policy_attachment" "execution" { + role = aws_iam_role.execution.name + policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy" +} + +resource "aws_iam_role_policy" "execution_secrets" { + count = length(local.secrets) > 0 ? 1 : 0 + name = "secrets-access" + role = aws_iam_role.execution.id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Effect = "Allow" + Action = ["secretsmanager:GetSecretValue"] + Resource = values(local.secrets) + }, + { + Effect = "Allow" + Action = ["ssm:GetParameters"] + Resource = values(local.secrets) + } + ] + }) +} + +################################################################################ +# IAM - Task Role (app permissions) +################################################################################ + +resource "aws_iam_role" "task" { + name = "${local.name}-task" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Effect = "Allow" + Action = "sts:AssumeRole" + Principal = { Service = "ecs-tasks.amazonaws.com" } + }] + }) + + tags = { Name = "${local.name}-task" } +} + +# Add app-specific permissions here +resource "aws_iam_role_policy" "task" { + name = "app-permissions" + role = aws_iam_role.task.id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Sid = "AllowTaggedResources" + Effect = "Allow" + Action = ["s3:GetObject", "s3:PutObject", "dynamodb:*"] + Resource = "*" + Condition = { StringEquals = { "aws:ResourceTag/Tenant" = local.tenant } } + } + ] + }) +} + +################################################################################ +# Task Definition +################################################################################ + +resource "aws_ecs_task_definition" "main" { + family = local.name + network_mode = "awsvpc" + requires_compatibilities = ["FARGATE"] + cpu = local.cpu + memory = local.memory + execution_role_arn = aws_iam_role.execution.arn + task_role_arn = aws_iam_role.task.arn + + container_definitions = jsonencode([ + { + name = local.app + image = local.container_image + essential = true + + portMappings = [{ + containerPort = local.container_port + protocol = "tcp" + }] + + environment = [ + for k, v in local.environment : { name = k, value = v } + ] + + secrets = [ + for k, v in local.secrets : { name = k, valueFrom = v } + ] + + logConfiguration = { + logDriver = "awslogs" + options = { + awslogs-group = aws_cloudwatch_log_group.main.name + awslogs-region = data.aws_region.current.name + awslogs-stream-prefix = "ecs" + } + } + + healthCheck = { + command = ["CMD-SHELL", "curl -f http://localhost:${local.container_port}${local.health_check_path} || exit 1"] + interval = 30 + timeout = 5 + retries = 3 + startPeriod = 60 + } + } + ]) + + tags = { Name = local.name } +} + +################################################################################ +# Security Group - Service +################################################################################ + +resource "aws_security_group" "service" { + name = "${local.name}-service" + description = "ECS service for ${local.name}" + vpc_id = data.terraform_remote_state.network.outputs.vpc_id + + egress { + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + description = "Allow all outbound traffic" + } + + tags = { Name = "${local.name}-service" } +} + +# Separate ingress rules to handle conditional ALB +resource "aws_security_group_rule" "service_from_alb" { + count = local.enable_alb ? 1 : 0 + type = "ingress" + from_port = local.container_port + to_port = local.container_port + protocol = "tcp" + source_security_group_id = aws_security_group.alb[0].id + security_group_id = aws_security_group.service.id + description = "From ALB" +} + +resource "aws_security_group_rule" "service_self" { + count = local.enable_alb ? 0 : 1 + type = "ingress" + from_port = local.container_port + to_port = local.container_port + protocol = "tcp" + self = true + security_group_id = aws_security_group.service.id + description = "Self-referencing for service mesh" +} + +################################################################################ +# ALB +################################################################################ + +resource "aws_security_group" "alb" { + count = local.enable_alb ? 1 : 0 + name = "${local.name}-alb" + description = "ALB for ${local.name}" + vpc_id = data.terraform_remote_state.network.outputs.vpc_id + + ingress { + description = "HTTPS" + from_port = 443 + to_port = 443 + protocol = "tcp" + cidr_blocks = ["0.0.0.0/0"] + } + + ingress { + description = "HTTP" + from_port = 80 + to_port = 80 + protocol = "tcp" + cidr_blocks = ["0.0.0.0/0"] + } + + egress { + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + } + + tags = { Name = "${local.name}-alb" } +} + +resource "aws_lb" "main" { + count = local.enable_alb ? 1 : 0 + name = local.short_name # ALB names max 32 chars + internal = false + load_balancer_type = "application" + security_groups = [aws_security_group.alb[0].id] + subnets = data.terraform_remote_state.network.outputs.public_subnet_ids + + # Security: Drop invalid headers + drop_invalid_header_fields = true + + # Access logging for audit trail + access_logs { + bucket = data.terraform_remote_state.bootstrap.outputs.logs_bucket + prefix = "alb/${local.name}" + enabled = true + } + + tags = { Name = local.name } +} + +resource "aws_lb_target_group" "main" { + count = local.enable_alb ? 1 : 0 + name = local.short_name # Target group names max 32 chars + port = local.container_port + protocol = "HTTP" + vpc_id = data.terraform_remote_state.network.outputs.vpc_id + target_type = "ip" + + health_check { + enabled = true + healthy_threshold = 2 + interval = 30 + matcher = "200" + path = local.health_check_path + port = "traffic-port" + timeout = 5 + unhealthy_threshold = 3 + } + + # Enable stickiness for stateful apps (disabled by default) + stickiness { + type = "lb_cookie" + enabled = false + cookie_duration = 86400 + } + + tags = { Name = local.name } +} + +resource "aws_lb_listener" "http" { + count = local.enable_alb ? 1 : 0 + load_balancer_arn = aws_lb.main[0].arn + port = "80" + protocol = "HTTP" + + default_action { + type = "forward" + target_group_arn = aws_lb_target_group.main[0].arn + } +} + +################################################################################ +# ECS Service +################################################################################ + +resource "aws_ecs_service" "main" { + name = local.app + cluster = aws_ecs_cluster.main.id + task_definition = aws_ecs_task_definition.main.arn + desired_count = local.desired_count + launch_type = "FARGATE" + + network_configuration { + subnets = data.terraform_remote_state.network.outputs.private_subnet_ids + security_groups = [aws_security_group.service.id, data.terraform_remote_state.tenant.outputs.security_groups.base] + assign_public_ip = false + } + + dynamic "load_balancer" { + for_each = local.enable_alb ? [1] : [] + content { + target_group_arn = aws_lb_target_group.main[0].arn + container_name = local.app + container_port = local.container_port + } + } + + lifecycle { + ignore_changes = [desired_count] # Managed by auto-scaling + } + + tags = { Name = local.name } +} + +################################################################################ +# Auto Scaling +################################################################################ + +resource "aws_appautoscaling_target" "ecs" { + max_capacity = local.max_count + min_capacity = local.min_count + resource_id = "service/${aws_ecs_cluster.main.name}/${aws_ecs_service.main.name}" + scalable_dimension = "ecs:service:DesiredCount" + service_namespace = "ecs" +} + +resource "aws_appautoscaling_policy" "cpu" { + name = "${local.name}-cpu" + policy_type = "TargetTrackingScaling" + resource_id = aws_appautoscaling_target.ecs.resource_id + scalable_dimension = aws_appautoscaling_target.ecs.scalable_dimension + service_namespace = aws_appautoscaling_target.ecs.service_namespace + + target_tracking_scaling_policy_configuration { + predefined_metric_specification { + predefined_metric_type = "ECSServiceAverageCPUUtilization" + } + target_value = 70 + scale_in_cooldown = 300 + scale_out_cooldown = 60 + } +} + +resource "aws_appautoscaling_policy" "memory" { + name = "${local.name}-memory" + policy_type = "TargetTrackingScaling" + resource_id = aws_appautoscaling_target.ecs.resource_id + scalable_dimension = aws_appautoscaling_target.ecs.scalable_dimension + service_namespace = aws_appautoscaling_target.ecs.service_namespace + + target_tracking_scaling_policy_configuration { + predefined_metric_specification { + predefined_metric_type = "ECSServiceAverageMemoryUtilization" + } + target_value = 80 + scale_in_cooldown = 300 + scale_out_cooldown = 60 + } +} + +################################################################################ +# Outputs +################################################################################ + +output "cluster_name" { + value = aws_ecs_cluster.main.name +} + +output "service_name" { + value = aws_ecs_service.main.name +} + +output "alb_dns_name" { + value = local.enable_alb ? aws_lb.main[0].dns_name : null +} + +output "alb_zone_id" { + value = local.enable_alb ? aws_lb.main[0].zone_id : null +} + +output "log_group" { + value = aws_cloudwatch_log_group.main.name +} + +output "task_role_arn" { + value = aws_iam_role.task.arn +} diff --git a/terraform/05-workloads/_template/eks-cluster/main.tf b/terraform/05-workloads/_template/eks-cluster/main.tf new file mode 100644 index 0000000..82b23dc --- /dev/null +++ b/terraform/05-workloads/_template/eks-cluster/main.tf @@ -0,0 +1,968 @@ +################################################################################ +# Workload: EKS Cluster +# +# Deploys a managed Kubernetes cluster: +# - EKS cluster with managed node groups +# - Core addons (VPC CNI, CoreDNS, kube-proxy) +# - IRSA (IAM Roles for Service Accounts) +# - Cluster Autoscaler ready +# - AWS Load Balancer Controller ready +# - Optional Fargate profiles +# +# Usage: +# Copy this folder to 05-workloads/-eks/ +# Update locals and variables +# terraform init -backend-config=../../00-bootstrap/backend.hcl +# terraform apply +################################################################################ + +terraform { + required_version = ">= 1.5" + + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 5.0" + } + tls = { + source = "hashicorp/tls" + version = ">= 4.0" + } + } + + backend "s3" { + key = "05-workloads/-eks/terraform.tfstate" + } +} + +################################################################################ +# Configuration - UPDATE THESE +################################################################################ + +locals { + # Naming + tenant = "" + env = "prod" # prod, staging, dev + name = "${local.tenant}-${local.env}" + + # EKS Version + cluster_version = "1.29" + + # Node Groups + node_groups = { + general = { + instance_types = ["t3.medium"] + capacity_type = "ON_DEMAND" # ON_DEMAND or SPOT + min_size = 2 + max_size = 10 + desired_size = 2 + disk_size = 50 + labels = { + role = "general" + } + taints = [] + } + # Uncomment for spot instances + # spot = { + # instance_types = ["t3.medium", "t3.large", "t3a.medium"] + # capacity_type = "SPOT" + # min_size = 0 + # max_size = 20 + # desired_size = 0 + # disk_size = 50 + # labels = { + # role = "spot" + # } + # taints = [{ + # key = "spot" + # value = "true" + # effect = "NO_SCHEDULE" + # }] + # } + } + + # Fargate (for serverless pods) + enable_fargate = false + fargate_namespaces = ["serverless"] # Namespaces to run on Fargate + + # Addons + enable_cluster_autoscaler = true + enable_aws_lb_controller = true + enable_ebs_csi_driver = true + enable_metrics_server = true + + # Logging + cluster_log_types = ["api", "audit", "authenticator", "controllerManager", "scheduler"] + log_retention_days = 30 + + # Access + cluster_endpoint_public = true + cluster_endpoint_private = true + public_access_cidrs = ["0.0.0.0/0"] # Restrict in production! + + # Admin access (IAM ARNs that can access cluster) + admin_arns = [ + # "arn:aws:iam::123456789012:role/Admin", + # "arn:aws:iam::123456789012:user/admin", + ] +} + +################################################################################ +# Variables +################################################################################ + +variable "region" { + type = string + default = "us-east-1" +} + +variable "state_bucket" { + type = string +} + +################################################################################ +# Provider +################################################################################ + +provider "aws" { + region = var.region + + default_tags { + tags = { + Tenant = local.tenant + Environment = local.env + ManagedBy = "terraform" + } + } +} + +################################################################################ +# Data Sources +################################################################################ + +data "terraform_remote_state" "network" { + backend = "s3" + config = { + bucket = var.state_bucket + key = "02-network/terraform.tfstate" + region = var.region + } +} + +data "terraform_remote_state" "tenant" { + backend = "s3" + config = { + bucket = var.state_bucket + key = "04-tenants/${local.tenant}/terraform.tfstate" + region = var.region + } +} + +data "aws_caller_identity" "current" {} +data "aws_region" "current" {} +data "aws_partition" "current" {} + +################################################################################ +# KMS Key for Secrets Encryption +################################################################################ + +resource "aws_kms_key" "eks" { + description = "EKS Secret Encryption Key for ${local.name}" + deletion_window_in_days = 7 + enable_key_rotation = true + + tags = { Name = "${local.name}-eks" } +} + +resource "aws_kms_alias" "eks" { + name = "alias/${local.name}-eks" + target_key_id = aws_kms_key.eks.key_id +} + +################################################################################ +# CloudWatch Log Group +################################################################################ + +resource "aws_cloudwatch_log_group" "eks" { + name = "/aws/eks/${local.name}/cluster" + retention_in_days = local.log_retention_days + + tags = { Name = "${local.name}-eks" } +} + +################################################################################ +# IAM - Cluster Role +################################################################################ + +resource "aws_iam_role" "cluster" { + name = "${local.name}-eks-cluster" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Effect = "Allow" + Action = "sts:AssumeRole" + Principal = { Service = "eks.amazonaws.com" } + }] + }) + + tags = { Name = "${local.name}-eks-cluster" } +} + +resource "aws_iam_role_policy_attachment" "cluster_policy" { + role = aws_iam_role.cluster.name + policy_arn = "arn:${data.aws_partition.current.partition}:iam::aws:policy/AmazonEKSClusterPolicy" +} + +resource "aws_iam_role_policy_attachment" "cluster_vpc_policy" { + role = aws_iam_role.cluster.name + policy_arn = "arn:${data.aws_partition.current.partition}:iam::aws:policy/AmazonEKSVPCResourceController" +} + +################################################################################ +# IAM - Node Role +################################################################################ + +resource "aws_iam_role" "node" { + name = "${local.name}-eks-node" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Effect = "Allow" + Action = "sts:AssumeRole" + Principal = { Service = "ec2.amazonaws.com" } + }] + }) + + tags = { Name = "${local.name}-eks-node" } +} + +resource "aws_iam_role_policy_attachment" "node_policy" { + role = aws_iam_role.node.name + policy_arn = "arn:${data.aws_partition.current.partition}:iam::aws:policy/AmazonEKSWorkerNodePolicy" +} + +resource "aws_iam_role_policy_attachment" "node_cni_policy" { + role = aws_iam_role.node.name + policy_arn = "arn:${data.aws_partition.current.partition}:iam::aws:policy/AmazonEKS_CNI_Policy" +} + +resource "aws_iam_role_policy_attachment" "node_ecr_policy" { + role = aws_iam_role.node.name + policy_arn = "arn:${data.aws_partition.current.partition}:iam::aws:policy/AmazonEC2ContainerRegistryReadOnly" +} + +resource "aws_iam_role_policy_attachment" "node_ssm_policy" { + role = aws_iam_role.node.name + policy_arn = "arn:${data.aws_partition.current.partition}:iam::aws:policy/AmazonSSMManagedInstanceCore" +} + +################################################################################ +# IAM - Fargate Role +################################################################################ + +resource "aws_iam_role" "fargate" { + count = local.enable_fargate ? 1 : 0 + name = "${local.name}-eks-fargate" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Effect = "Allow" + Action = "sts:AssumeRole" + Principal = { Service = "eks-fargate-pods.amazonaws.com" } + }] + }) + + tags = { Name = "${local.name}-eks-fargate" } +} + +resource "aws_iam_role_policy_attachment" "fargate_policy" { + count = local.enable_fargate ? 1 : 0 + role = aws_iam_role.fargate[0].name + policy_arn = "arn:${data.aws_partition.current.partition}:iam::aws:policy/AmazonEKSFargatePodExecutionRolePolicy" +} + +################################################################################ +# Security Groups +################################################################################ + +resource "aws_security_group" "cluster" { + name = "${local.name}-eks-cluster" + description = "EKS cluster security group" + vpc_id = data.terraform_remote_state.network.outputs.vpc_id + + tags = { Name = "${local.name}-eks-cluster" } +} + +resource "aws_security_group_rule" "cluster_egress" { + type = "egress" + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + security_group_id = aws_security_group.cluster.id + description = "Allow all outbound" +} + +resource "aws_security_group" "node" { + name = "${local.name}-eks-node" + description = "EKS node security group" + vpc_id = data.terraform_remote_state.network.outputs.vpc_id + + tags = { + Name = "${local.name}-eks-node" + "kubernetes.io/cluster/${local.name}" = "owned" + } +} + +resource "aws_security_group_rule" "node_egress" { + type = "egress" + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + security_group_id = aws_security_group.node.id + description = "Allow all outbound" +} + +resource "aws_security_group_rule" "node_ingress_self" { + type = "ingress" + from_port = 0 + to_port = 65535 + protocol = "-1" + source_security_group_id = aws_security_group.node.id + security_group_id = aws_security_group.node.id + description = "Node to node" +} + +resource "aws_security_group_rule" "node_ingress_cluster" { + type = "ingress" + from_port = 443 + to_port = 443 + protocol = "tcp" + source_security_group_id = aws_security_group.cluster.id + security_group_id = aws_security_group.node.id + description = "Cluster to node (webhooks)" +} + +resource "aws_security_group_rule" "node_ingress_cluster_kubelet" { + type = "ingress" + from_port = 10250 + to_port = 10250 + protocol = "tcp" + source_security_group_id = aws_security_group.cluster.id + security_group_id = aws_security_group.node.id + description = "Cluster to node (kubelet)" +} + +resource "aws_security_group_rule" "cluster_ingress_node" { + type = "ingress" + from_port = 443 + to_port = 443 + protocol = "tcp" + source_security_group_id = aws_security_group.node.id + security_group_id = aws_security_group.cluster.id + description = "Node to cluster API" +} + +################################################################################ +# EKS Cluster +################################################################################ + +resource "aws_eks_cluster" "main" { + name = local.name + version = local.cluster_version + role_arn = aws_iam_role.cluster.arn + + vpc_config { + subnet_ids = data.terraform_remote_state.network.outputs.private_subnet_ids + endpoint_private_access = local.cluster_endpoint_private + endpoint_public_access = local.cluster_endpoint_public + public_access_cidrs = local.public_access_cidrs + security_group_ids = [aws_security_group.cluster.id] + } + + encryption_config { + provider { + key_arn = aws_kms_key.eks.arn + } + resources = ["secrets"] + } + + enabled_cluster_log_types = local.cluster_log_types + + depends_on = [ + aws_iam_role_policy_attachment.cluster_policy, + aws_iam_role_policy_attachment.cluster_vpc_policy, + aws_cloudwatch_log_group.eks, + ] + + tags = { Name = local.name } +} + +################################################################################ +# EKS Addons +################################################################################ + +resource "aws_eks_addon" "vpc_cni" { + cluster_name = aws_eks_cluster.main.name + addon_name = "vpc-cni" + + resolve_conflicts_on_create = "OVERWRITE" + resolve_conflicts_on_update = "OVERWRITE" + + tags = { Name = "${local.name}-vpc-cni" } +} + +resource "aws_eks_addon" "coredns" { + cluster_name = aws_eks_cluster.main.name + addon_name = "coredns" + + resolve_conflicts_on_create = "OVERWRITE" + resolve_conflicts_on_update = "OVERWRITE" + + depends_on = [aws_eks_node_group.main] + + tags = { Name = "${local.name}-coredns" } +} + +resource "aws_eks_addon" "kube_proxy" { + cluster_name = aws_eks_cluster.main.name + addon_name = "kube-proxy" + + resolve_conflicts_on_create = "OVERWRITE" + resolve_conflicts_on_update = "OVERWRITE" + + tags = { Name = "${local.name}-kube-proxy" } +} + +resource "aws_eks_addon" "ebs_csi" { + count = local.enable_ebs_csi_driver ? 1 : 0 + cluster_name = aws_eks_cluster.main.name + addon_name = "aws-ebs-csi-driver" + + service_account_role_arn = aws_iam_role.ebs_csi[0].arn + + resolve_conflicts_on_create = "OVERWRITE" + resolve_conflicts_on_update = "OVERWRITE" + + depends_on = [aws_eks_node_group.main] + + tags = { Name = "${local.name}-ebs-csi" } +} + +################################################################################ +# Node Groups +################################################################################ + +resource "aws_eks_node_group" "main" { + for_each = local.node_groups + + cluster_name = aws_eks_cluster.main.name + node_group_name = "${local.name}-${each.key}" + node_role_arn = aws_iam_role.node.arn + subnet_ids = data.terraform_remote_state.network.outputs.private_subnet_ids + + instance_types = each.value.instance_types + capacity_type = each.value.capacity_type + disk_size = each.value.disk_size + + scaling_config { + min_size = each.value.min_size + max_size = each.value.max_size + desired_size = each.value.desired_size + } + + update_config { + max_unavailable = 1 + } + + labels = merge(each.value.labels, { + Tenant = local.tenant + }) + + dynamic "taint" { + for_each = each.value.taints + content { + key = taint.value.key + value = taint.value.value + effect = taint.value.effect + } + } + + # Launch template for security hardening + launch_template { + id = aws_launch_template.node[each.key].id + version = aws_launch_template.node[each.key].latest_version + } + + depends_on = [ + aws_iam_role_policy_attachment.node_policy, + aws_iam_role_policy_attachment.node_cni_policy, + aws_iam_role_policy_attachment.node_ecr_policy, + ] + + lifecycle { + ignore_changes = [scaling_config[0].desired_size] + } + + tags = { Name = "${local.name}-${each.key}" } +} + +################################################################################ +# Launch Template for Node Security Hardening +################################################################################ + +resource "aws_launch_template" "node" { + for_each = local.node_groups + + name = "${local.name}-${each.key}" + + # IMDSv2 enforcement - critical security control + metadata_options { + http_endpoint = "enabled" + http_tokens = "required" # Enforces IMDSv2 + http_put_response_hop_limit = 1 # Prevent container credential theft + instance_metadata_tags = "enabled" + } + + # EBS encryption + block_device_mappings { + device_name = "/dev/xvda" + + ebs { + volume_size = each.value.disk_size + volume_type = "gp3" + encrypted = true + delete_on_termination = true + } + } + + # Monitoring + monitoring { + enabled = true + } + + tag_specifications { + resource_type = "instance" + tags = { + Name = "${local.name}-${each.key}" + Tenant = local.tenant + } + } + + tags = { Name = "${local.name}-${each.key}" } +} + +################################################################################ +# Fargate Profiles +################################################################################ + +resource "aws_eks_fargate_profile" "main" { + for_each = local.enable_fargate ? toset(local.fargate_namespaces) : [] + + cluster_name = aws_eks_cluster.main.name + fargate_profile_name = "${local.name}-${each.key}" + pod_execution_role_arn = aws_iam_role.fargate[0].arn + subnet_ids = data.terraform_remote_state.network.outputs.private_subnet_ids + + selector { + namespace = each.key + labels = { + Tenant = local.tenant + } + } + + tags = { Name = "${local.name}-${each.key}" } +} + +################################################################################ +# OIDC Provider for IRSA +################################################################################ + +data "tls_certificate" "eks" { + url = aws_eks_cluster.main.identity[0].oidc[0].issuer +} + +resource "aws_iam_openid_connect_provider" "eks" { + client_id_list = ["sts.amazonaws.com"] + thumbprint_list = [data.tls_certificate.eks.certificates[0].sha1_fingerprint] + url = aws_eks_cluster.main.identity[0].oidc[0].issuer + + tags = { Name = "${local.name}-eks-oidc" } +} + +################################################################################ +# IRSA - EBS CSI Driver +################################################################################ + +resource "aws_iam_role" "ebs_csi" { + count = local.enable_ebs_csi_driver ? 1 : 0 + name = "${local.name}-ebs-csi" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Effect = "Allow" + Action = "sts:AssumeRoleWithWebIdentity" + Principal = { + Federated = aws_iam_openid_connect_provider.eks.arn + } + Condition = { + StringEquals = { + "${replace(aws_iam_openid_connect_provider.eks.url, "https://", "")}:sub" = "system:serviceaccount:kube-system:ebs-csi-controller-sa" + "${replace(aws_iam_openid_connect_provider.eks.url, "https://", "")}:aud" = "sts.amazonaws.com" + } + } + }] + }) + + tags = { Name = "${local.name}-ebs-csi" } +} + +resource "aws_iam_role_policy_attachment" "ebs_csi" { + count = local.enable_ebs_csi_driver ? 1 : 0 + role = aws_iam_role.ebs_csi[0].name + policy_arn = "arn:${data.aws_partition.current.partition}:iam::aws:policy/service-role/AmazonEBSCSIDriverPolicy" +} + +################################################################################ +# IRSA - Cluster Autoscaler +################################################################################ + +resource "aws_iam_role" "cluster_autoscaler" { + count = local.enable_cluster_autoscaler ? 1 : 0 + name = "${local.name}-cluster-autoscaler" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Effect = "Allow" + Action = "sts:AssumeRoleWithWebIdentity" + Principal = { + Federated = aws_iam_openid_connect_provider.eks.arn + } + Condition = { + StringEquals = { + "${replace(aws_iam_openid_connect_provider.eks.url, "https://", "")}:sub" = "system:serviceaccount:kube-system:cluster-autoscaler" + "${replace(aws_iam_openid_connect_provider.eks.url, "https://", "")}:aud" = "sts.amazonaws.com" + } + } + }] + }) + + tags = { Name = "${local.name}-cluster-autoscaler" } +} + +resource "aws_iam_role_policy" "cluster_autoscaler" { + count = local.enable_cluster_autoscaler ? 1 : 0 + name = "cluster-autoscaler" + role = aws_iam_role.cluster_autoscaler[0].id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Effect = "Allow" + Action = [ + "autoscaling:DescribeAutoScalingGroups", + "autoscaling:DescribeAutoScalingInstances", + "autoscaling:DescribeLaunchConfigurations", + "autoscaling:DescribeScalingActivities", + "autoscaling:DescribeTags", + "ec2:DescribeInstanceTypes", + "ec2:DescribeLaunchTemplateVersions", + "ec2:DescribeImages", + "ec2:GetInstanceTypesFromInstanceRequirements", + "eks:DescribeNodegroup" + ] + Resource = "*" + }, + { + Effect = "Allow" + Action = [ + "autoscaling:SetDesiredCapacity", + "autoscaling:TerminateInstanceInAutoScalingGroup" + ] + Resource = "*" + Condition = { + StringEquals = { + "autoscaling:ResourceTag/k8s.io/cluster-autoscaler/${local.name}" = "owned" + } + } + } + ] + }) +} + +################################################################################ +# IRSA - AWS Load Balancer Controller +################################################################################ + +resource "aws_iam_role" "lb_controller" { + count = local.enable_aws_lb_controller ? 1 : 0 + name = "${local.name}-aws-lb-controller" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Effect = "Allow" + Action = "sts:AssumeRoleWithWebIdentity" + Principal = { + Federated = aws_iam_openid_connect_provider.eks.arn + } + Condition = { + StringEquals = { + "${replace(aws_iam_openid_connect_provider.eks.url, "https://", "")}:sub" = "system:serviceaccount:kube-system:aws-load-balancer-controller" + "${replace(aws_iam_openid_connect_provider.eks.url, "https://", "")}:aud" = "sts.amazonaws.com" + } + } + }] + }) + + tags = { Name = "${local.name}-aws-lb-controller" } +} + +resource "aws_iam_role_policy" "lb_controller" { + count = local.enable_aws_lb_controller ? 1 : 0 + name = "aws-lb-controller" + role = aws_iam_role.lb_controller[0].id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Effect = "Allow" + Action = ["iam:CreateServiceLinkedRole"] + Resource = "*" + Condition = { + StringEquals = { + "iam:AWSServiceName" = "elasticloadbalancing.amazonaws.com" + } + } + }, + { + Effect = "Allow" + Action = [ + "ec2:DescribeAccountAttributes", + "ec2:DescribeAddresses", + "ec2:DescribeAvailabilityZones", + "ec2:DescribeInternetGateways", + "ec2:DescribeVpcs", + "ec2:DescribeVpcPeeringConnections", + "ec2:DescribeSubnets", + "ec2:DescribeSecurityGroups", + "ec2:DescribeInstances", + "ec2:DescribeNetworkInterfaces", + "ec2:DescribeTags", + "ec2:GetCoipPoolUsage", + "ec2:DescribeCoipPools", + "elasticloadbalancing:DescribeLoadBalancers", + "elasticloadbalancing:DescribeLoadBalancerAttributes", + "elasticloadbalancing:DescribeListeners", + "elasticloadbalancing:DescribeListenerCertificates", + "elasticloadbalancing:DescribeSSLPolicies", + "elasticloadbalancing:DescribeRules", + "elasticloadbalancing:DescribeTargetGroups", + "elasticloadbalancing:DescribeTargetGroupAttributes", + "elasticloadbalancing:DescribeTargetHealth", + "elasticloadbalancing:DescribeTags" + ] + Resource = "*" + }, + { + Effect = "Allow" + Action = [ + "cognito-idp:DescribeUserPoolClient", + "acm:ListCertificates", + "acm:DescribeCertificate", + "iam:ListServerCertificates", + "iam:GetServerCertificate", + "waf-regional:GetWebACL", + "waf-regional:GetWebACLForResource", + "waf-regional:AssociateWebACL", + "waf-regional:DisassociateWebACL", + "wafv2:GetWebACL", + "wafv2:GetWebACLForResource", + "wafv2:AssociateWebACL", + "wafv2:DisassociateWebACL", + "shield:GetSubscriptionState", + "shield:DescribeProtection", + "shield:CreateProtection", + "shield:DeleteProtection" + ] + Resource = "*" + }, + { + Effect = "Allow" + Action = [ + "ec2:AuthorizeSecurityGroupIngress", + "ec2:RevokeSecurityGroupIngress", + "ec2:CreateSecurityGroup", + "ec2:CreateTags", + "ec2:DeleteTags", + "ec2:DeleteSecurityGroup" + ] + Resource = "*" + }, + { + Effect = "Allow" + Action = [ + "elasticloadbalancing:CreateLoadBalancer", + "elasticloadbalancing:CreateTargetGroup" + ] + Resource = "*" + Condition = { + Null = { + "aws:RequestTag/elbv2.k8s.aws/cluster" = "false" + } + } + }, + { + Effect = "Allow" + Action = [ + "elasticloadbalancing:AddTags", + "elasticloadbalancing:RemoveTags" + ] + Resource = [ + "arn:${data.aws_partition.current.partition}:elasticloadbalancing:*:*:targetgroup/*/*", + "arn:${data.aws_partition.current.partition}:elasticloadbalancing:*:*:loadbalancer/net/*/*", + "arn:${data.aws_partition.current.partition}:elasticloadbalancing:*:*:loadbalancer/app/*/*" + ] + }, + { + Effect = "Allow" + Action = [ + "elasticloadbalancing:ModifyLoadBalancerAttributes", + "elasticloadbalancing:SetIpAddressType", + "elasticloadbalancing:SetSecurityGroups", + "elasticloadbalancing:SetSubnets", + "elasticloadbalancing:DeleteLoadBalancer", + "elasticloadbalancing:ModifyTargetGroup", + "elasticloadbalancing:ModifyTargetGroupAttributes", + "elasticloadbalancing:DeleteTargetGroup", + "elasticloadbalancing:RegisterTargets", + "elasticloadbalancing:DeregisterTargets", + "elasticloadbalancing:CreateListener", + "elasticloadbalancing:DeleteListener", + "elasticloadbalancing:CreateRule", + "elasticloadbalancing:ModifyRule", + "elasticloadbalancing:DeleteRule", + "elasticloadbalancing:SetWebAcl", + "elasticloadbalancing:ModifyListener", + "elasticloadbalancing:AddListenerCertificates", + "elasticloadbalancing:RemoveListenerCertificates" + ] + Resource = "*" + } + ] + }) +} + +################################################################################ +# EKS Access Entries (K8s 1.29+) +################################################################################ + +resource "aws_eks_access_entry" "admins" { + for_each = toset(local.admin_arns) + + cluster_name = aws_eks_cluster.main.name + principal_arn = each.value + type = "STANDARD" +} + +resource "aws_eks_access_policy_association" "admins" { + for_each = toset(local.admin_arns) + + cluster_name = aws_eks_cluster.main.name + principal_arn = each.value + policy_arn = "arn:${data.aws_partition.current.partition}:eks::aws:cluster-access-policy/AmazonEKSClusterAdminPolicy" + + access_scope { + type = "cluster" + } + + depends_on = [aws_eks_access_entry.admins] +} + +################################################################################ +# Outputs +################################################################################ + +output "cluster_name" { + value = aws_eks_cluster.main.name +} + +output "cluster_endpoint" { + value = aws_eks_cluster.main.endpoint +} + +output "cluster_ca_certificate" { + value = aws_eks_cluster.main.certificate_authority[0].data + sensitive = true +} + +output "cluster_version" { + value = aws_eks_cluster.main.version +} + +output "cluster_security_group_id" { + value = aws_security_group.cluster.id +} + +output "node_security_group_id" { + value = aws_security_group.node.id +} + +output "oidc_provider_arn" { + value = aws_iam_openid_connect_provider.eks.arn +} + +output "oidc_provider_url" { + value = aws_iam_openid_connect_provider.eks.url +} + +output "cluster_autoscaler_role_arn" { + value = local.enable_cluster_autoscaler ? aws_iam_role.cluster_autoscaler[0].arn : null +} + +output "lb_controller_role_arn" { + value = local.enable_aws_lb_controller ? aws_iam_role.lb_controller[0].arn : null +} + +output "kubeconfig_command" { + value = "aws eks update-kubeconfig --region ${data.aws_region.current.name} --name ${aws_eks_cluster.main.name}" +} + +output "next_steps" { + value = <<-EOT + + EKS Cluster Created: ${aws_eks_cluster.main.name} + ============================================= + + 1. Configure kubectl: + ${local.enable_cluster_autoscaler ? "aws eks update-kubeconfig --region ${data.aws_region.current.name} --name ${aws_eks_cluster.main.name}" : ""} + + 2. Install Cluster Autoscaler (if enabled): + helm repo add autoscaler https://kubernetes.github.io/autoscaler + helm install cluster-autoscaler autoscaler/cluster-autoscaler \ + --namespace kube-system \ + --set autoDiscovery.clusterName=${aws_eks_cluster.main.name} \ + --set awsRegion=${data.aws_region.current.name} \ + --set rbac.serviceAccount.create=true \ + --set rbac.serviceAccount.name=cluster-autoscaler \ + --set rbac.serviceAccount.annotations."eks\.amazonaws\.com/role-arn"=${local.enable_cluster_autoscaler ? aws_iam_role.cluster_autoscaler[0].arn : "N/A"} + + 3. Install AWS Load Balancer Controller (if enabled): + helm repo add eks https://aws.github.io/eks-charts + helm install aws-load-balancer-controller eks/aws-load-balancer-controller \ + --namespace kube-system \ + --set clusterName=${aws_eks_cluster.main.name} \ + --set serviceAccount.create=true \ + --set serviceAccount.name=aws-load-balancer-controller \ + --set serviceAccount.annotations."eks\.amazonaws\.com/role-arn"=${local.enable_aws_lb_controller ? aws_iam_role.lb_controller[0].arn : "N/A"} + + EOT +} diff --git a/terraform/05-workloads/_template/elasticache-redis/main.tf b/terraform/05-workloads/_template/elasticache-redis/main.tf new file mode 100644 index 0000000..1dab3df --- /dev/null +++ b/terraform/05-workloads/_template/elasticache-redis/main.tf @@ -0,0 +1,389 @@ +################################################################################ +# Workload: ElastiCache Redis +# +# Deploys a managed Redis cluster: +# - Redis cluster or replication group +# - Encryption at rest and in transit +# - Automatic failover (Multi-AZ) +# - CloudWatch alarms +# +# Usage: +# Copy this folder to 05-workloads/-cache/ +# Update locals and variables +# terraform init -backend-config=../../00-bootstrap/backend.hcl +# terraform apply +################################################################################ + +terraform { + required_version = ">= 1.5" + + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 5.0" + } + } + + backend "s3" { + key = "05-workloads/-cache/terraform.tfstate" + } +} + +################################################################################ +# Configuration - UPDATE THESE +################################################################################ + +locals { + # Naming + tenant = "" + app = "cache" + env = "prod" # prod, staging, dev + name = "${local.tenant}-${local.app}-${local.env}" + + # Redis version + engine_version = "7.1" + + # Node sizing + # cache.t3.micro - Dev/test ($0.017/hr) + # cache.t3.small - Small prod ($0.034/hr) + # cache.r6g.large - Production ($0.158/hr) + node_type = "cache.t3.micro" + + # Cluster configuration + num_cache_clusters = local.env == "prod" ? 2 : 1 # 2 for Multi-AZ + automatic_failover = local.env == "prod" + multi_az_enabled = local.env == "prod" + + # Memory management + maxmemory_policy = "volatile-lru" # Evict keys with TTL when memory full + + # Maintenance + maintenance_window = "sun:05:00-sun:06:00" + snapshot_window = "04:00-05:00" + snapshot_retention = local.env == "prod" ? 7 : 1 + + # Port + port = 6379 +} + +################################################################################ +# Variables +################################################################################ + +variable "region" { + type = string + default = "us-east-1" +} + +variable "state_bucket" { + type = string +} + +################################################################################ +# Provider +################################################################################ + +provider "aws" { + region = var.region + + default_tags { + tags = { + Tenant = local.tenant + App = local.app + Environment = local.env + ManagedBy = "terraform" + } + } +} + +################################################################################ +# Data Sources +################################################################################ + +data "terraform_remote_state" "network" { + backend = "s3" + config = { + bucket = var.state_bucket + key = "02-network/terraform.tfstate" + region = var.region + } +} + +data "terraform_remote_state" "tenant" { + backend = "s3" + config = { + bucket = var.state_bucket + key = "04-tenants/${local.tenant}/terraform.tfstate" + region = var.region + } +} + +data "aws_caller_identity" "current" {} + +################################################################################ +# KMS Key +################################################################################ + +resource "aws_kms_key" "redis" { + description = "KMS key for ${local.name} Redis encryption" + deletion_window_in_days = 7 + enable_key_rotation = true + + tags = { Name = "${local.name}-redis" } +} + +resource "aws_kms_alias" "redis" { + name = "alias/${local.name}-redis" + target_key_id = aws_kms_key.redis.key_id +} + +################################################################################ +# Subnet Group +################################################################################ + +resource "aws_elasticache_subnet_group" "main" { + name = local.name + description = "Subnet group for ${local.name}" + subnet_ids = data.terraform_remote_state.network.outputs.private_subnet_ids + + tags = { Name = local.name } +} + +################################################################################ +# Parameter Group +################################################################################ + +resource "aws_elasticache_parameter_group" "main" { + name = local.name + family = "redis7" + description = "Parameter group for ${local.name}" + + parameter { + name = "maxmemory-policy" + value = local.maxmemory_policy + } + + # Cluster mode disabled settings + parameter { + name = "cluster-enabled" + value = "no" + } + + # Slow log for debugging + parameter { + name = "slowlog-log-slower-than" + value = "10000" # 10ms + } + + parameter { + name = "slowlog-max-len" + value = "128" + } + + tags = { Name = local.name } +} + +################################################################################ +# Security Group +################################################################################ + +resource "aws_security_group" "redis" { + name = "${local.name}-redis" + description = "Redis cluster ${local.name}" + vpc_id = data.terraform_remote_state.network.outputs.vpc_id + + ingress { + description = "Redis from tenant" + from_port = local.port + to_port = local.port + protocol = "tcp" + security_groups = [data.terraform_remote_state.tenant.outputs.security_groups.base] + } + + egress { + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + description = "Allow all outbound" + } + + tags = { Name = "${local.name}-redis" } +} + +################################################################################ +# Replication Group (Redis Cluster) +################################################################################ + +resource "aws_elasticache_replication_group" "main" { + replication_group_id = local.name + description = "Redis cluster for ${local.name}" + + engine = "redis" + engine_version = local.engine_version + node_type = local.node_type + port = local.port + parameter_group_name = aws_elasticache_parameter_group.main.name + + # Cluster configuration + num_cache_clusters = local.num_cache_clusters + automatic_failover_enabled = local.automatic_failover + multi_az_enabled = local.multi_az_enabled + + # Network + subnet_group_name = aws_elasticache_subnet_group.main.name + security_group_ids = [aws_security_group.redis.id] + + # Encryption + at_rest_encryption_enabled = true + kms_key_id = aws_kms_key.redis.arn + transit_encryption_enabled = true + auth_token = random_password.auth.result + + # Maintenance + maintenance_window = local.maintenance_window + snapshot_window = local.snapshot_window + snapshot_retention_limit = local.snapshot_retention + auto_minor_version_upgrade = true + + # Notifications + notification_topic_arn = aws_sns_topic.redis.arn + + # Apply changes immediately in non-prod, during maintenance in prod + apply_immediately = local.env != "prod" + + tags = { + Name = local.name + Backup = "true" + } +} + +################################################################################ +# Auth Token (Password) +################################################################################ + +resource "random_password" "auth" { + length = 64 + special = false # Redis auth token doesn't support all special chars +} + +resource "aws_secretsmanager_secret" "redis" { + name = "${local.name}-redis-auth" + description = "Redis auth token for ${local.name}" + recovery_window_in_days = local.env == "prod" ? 30 : 0 + + tags = { Name = "${local.name}-redis-auth" } +} + +resource "aws_secretsmanager_secret_version" "redis" { + secret_id = aws_secretsmanager_secret.redis.id + secret_string = jsonencode({ + auth_token = random_password.auth.result + host = aws_elasticache_replication_group.main.primary_endpoint_address + port = local.port + url = "rediss://:${random_password.auth.result}@${aws_elasticache_replication_group.main.primary_endpoint_address}:${local.port}" + }) +} + +################################################################################ +# SNS Topic for Notifications +################################################################################ + +resource "aws_sns_topic" "redis" { + name = "${local.name}-redis-events" + + tags = { Name = "${local.name}-redis-events" } +} + +################################################################################ +# CloudWatch Alarms +################################################################################ + +resource "aws_cloudwatch_metric_alarm" "cpu" { + alarm_name = "${local.name}-redis-cpu" + comparison_operator = "GreaterThanThreshold" + evaluation_periods = 3 + metric_name = "CPUUtilization" + namespace = "AWS/ElastiCache" + period = 300 + statistic = "Average" + threshold = 75 + alarm_description = "Redis CPU utilization high" + + dimensions = { + CacheClusterId = "${aws_elasticache_replication_group.main.id}-001" + } + + alarm_actions = [aws_sns_topic.redis.arn] + + tags = { Name = "${local.name}-redis-cpu" } +} + +resource "aws_cloudwatch_metric_alarm" "memory" { + alarm_name = "${local.name}-redis-memory" + comparison_operator = "GreaterThanThreshold" + evaluation_periods = 2 + metric_name = "DatabaseMemoryUsagePercentage" + namespace = "AWS/ElastiCache" + period = 300 + statistic = "Average" + threshold = 80 + alarm_description = "Redis memory usage high" + + dimensions = { + CacheClusterId = "${aws_elasticache_replication_group.main.id}-001" + } + + alarm_actions = [aws_sns_topic.redis.arn] + + tags = { Name = "${local.name}-redis-memory" } +} + +resource "aws_cloudwatch_metric_alarm" "connections" { + alarm_name = "${local.name}-redis-connections" + comparison_operator = "GreaterThanThreshold" + evaluation_periods = 2 + metric_name = "CurrConnections" + namespace = "AWS/ElastiCache" + period = 300 + statistic = "Average" + threshold = 1000 + alarm_description = "Redis connections high" + + dimensions = { + CacheClusterId = "${aws_elasticache_replication_group.main.id}-001" + } + + alarm_actions = [aws_sns_topic.redis.arn] + + tags = { Name = "${local.name}-redis-connections" } +} + +################################################################################ +# Outputs +################################################################################ + +output "primary_endpoint" { + value = aws_elasticache_replication_group.main.primary_endpoint_address +} + +output "reader_endpoint" { + value = aws_elasticache_replication_group.main.reader_endpoint_address +} + +output "port" { + value = local.port +} + +output "secret_arn" { + value = aws_secretsmanager_secret.redis.arn +} + +output "security_group_id" { + value = aws_security_group.redis.id +} + +output "connection_command" { + value = "redis-cli -h ${aws_elasticache_replication_group.main.primary_endpoint_address} -p ${local.port} --tls --askpass" + description = "Command to connect (retrieve password from Secrets Manager)" +} diff --git a/terraform/05-workloads/_template/eventbridge-bus/main.tf b/terraform/05-workloads/_template/eventbridge-bus/main.tf new file mode 100644 index 0000000..a2f60fe --- /dev/null +++ b/terraform/05-workloads/_template/eventbridge-bus/main.tf @@ -0,0 +1,385 @@ +################################################################################ +# Workload: EventBridge Event Bus +# +# Deploys an event-driven architecture component: +# - Custom event bus for tenant isolation +# - Event rules with pattern matching +# - Multiple targets (Lambda, SQS, Step Functions) +# - Dead letter queue for failed events +# - Event archiving for replay +# +# Usage: +# Copy this folder to 05-workloads/-events/ +# Update locals and rules +# terraform init -backend-config=../../00-bootstrap/backend.hcl +# terraform apply +################################################################################ + +terraform { + required_version = ">= 1.5" + + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 5.0" + } + } + + backend "s3" { + key = "05-workloads/-events/terraform.tfstate" + } +} + +################################################################################ +# Configuration - UPDATE THESE +################################################################################ + +locals { + # Naming + tenant = "" + name = "events" + env = "prod" + + bus_name = "${local.tenant}-${local.name}-${local.env}" + + # Event archiving (for replay capability) + enable_archive = true + archive_retention_days = 30 + + # Dead letter queue for failed event delivery + enable_dlq = true + + # Schema discovery (for event schema registry) + enable_schema_discovery = false + + # Event rules - define your event routing here + event_rules = { + # Example: Route order events to SQS + # order-created = { + # description = "Route order.created events to processing queue" + # event_pattern = { + # source = ["${local.tenant}.orders"] + # detail-type = ["order.created"] + # } + # targets = { + # sqs = { + # type = "sqs" + # arn = "arn:aws:sqs:us-east-1:123456789012:order-processing" + # } + # } + # } + + # Example: Route all events to CloudWatch Logs for debugging + all-events-log = { + description = "Log all events for debugging" + event_pattern = { + source = [{ prefix = "${local.tenant}." }] + } + targets = { + logs = { + type = "cloudwatch" + } + } + } + } + + # Cross-account event sources (account IDs that can put events) + allowed_source_accounts = [] + + # Cross-account event targets (account IDs that can receive events) + allowed_target_accounts = [] +} + +################################################################################ +# Variables +################################################################################ + +variable "region" { + type = string + default = "us-east-1" +} + +variable "state_bucket" { + type = string +} + +################################################################################ +# Provider +################################################################################ + +provider "aws" { + region = var.region + + default_tags { + tags = { + Tenant = local.tenant + App = local.name + Environment = local.env + ManagedBy = "terraform" + } + } +} + +################################################################################ +# Data Sources +################################################################################ + +data "aws_caller_identity" "current" {} +data "aws_region" "current" {} + +################################################################################ +# Event Bus +################################################################################ + +resource "aws_cloudwatch_event_bus" "main" { + name = local.bus_name + + tags = { Name = local.bus_name } +} + +################################################################################ +# Event Bus Policy +################################################################################ + +resource "aws_cloudwatch_event_bus_policy" "main" { + count = length(local.allowed_source_accounts) > 0 ? 1 : 0 + event_bus_name = aws_cloudwatch_event_bus.main.name + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Sid = "AllowCrossAccountPutEvents" + Effect = "Allow" + Principal = { + AWS = [for account in local.allowed_source_accounts : "arn:aws:iam::${account}:root"] + } + Action = "events:PutEvents" + Resource = aws_cloudwatch_event_bus.main.arn + } + ] + }) +} + +################################################################################ +# Event Archive +################################################################################ + +resource "aws_cloudwatch_event_archive" "main" { + count = local.enable_archive ? 1 : 0 + name = local.bus_name + description = "Archive for ${local.bus_name}" + event_source_arn = aws_cloudwatch_event_bus.main.arn + retention_days = local.archive_retention_days + + # Archive all events (can be filtered with event_pattern) +} + +################################################################################ +# Dead Letter Queue +################################################################################ + +resource "aws_sqs_queue" "dlq" { + count = local.enable_dlq ? 1 : 0 + name = "${local.bus_name}-dlq" + + message_retention_seconds = 1209600 # 14 days + kms_master_key_id = "alias/aws/sqs" + + tags = { Name = "${local.bus_name}-dlq" } +} + +resource "aws_sqs_queue_policy" "dlq" { + count = local.enable_dlq ? 1 : 0 + queue_url = aws_sqs_queue.dlq[0].id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Sid = "AllowEventBridge" + Effect = "Allow" + Principal = { + Service = "events.amazonaws.com" + } + Action = "sqs:SendMessage" + Resource = aws_sqs_queue.dlq[0].arn + }] + }) +} + +################################################################################ +# CloudWatch Log Group for Event Logging +################################################################################ + +resource "aws_cloudwatch_log_group" "events" { + name = "/aws/events/${local.bus_name}" + retention_in_days = 30 + + tags = { Name = local.bus_name } +} + +# Resource policy to allow EventBridge to write logs +resource "aws_cloudwatch_log_resource_policy" "events" { + policy_name = "${local.bus_name}-events" + + policy_document = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Effect = "Allow" + Principal = { + Service = ["events.amazonaws.com", "delivery.logs.amazonaws.com"] + } + Action = ["logs:CreateLogStream", "logs:PutLogEvents"] + Resource = "${aws_cloudwatch_log_group.events.arn}:*" + }] + }) +} + +################################################################################ +# IAM Role for EventBridge Targets +################################################################################ + +resource "aws_iam_role" "eventbridge" { + name = "${local.bus_name}-eventbridge" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Effect = "Allow" + Action = "sts:AssumeRole" + Principal = { Service = "events.amazonaws.com" } + }] + }) + + tags = { Name = "${local.bus_name}-eventbridge" } +} + +################################################################################ +# Event Rules and Targets +################################################################################ + +resource "aws_cloudwatch_event_rule" "rules" { + for_each = local.event_rules + + name = "${local.bus_name}-${each.key}" + description = each.value.description + event_bus_name = aws_cloudwatch_event_bus.main.name + event_pattern = jsonencode(each.value.event_pattern) + state = "ENABLED" + + tags = { Name = "${local.bus_name}-${each.key}" } +} + +# CloudWatch Logs targets +resource "aws_cloudwatch_event_target" "logs" { + for_each = { + for k, v in local.event_rules : k => v + if contains(keys(v.targets), "logs") && v.targets.logs.type == "cloudwatch" + } + + rule = aws_cloudwatch_event_rule.rules[each.key].name + event_bus_name = aws_cloudwatch_event_bus.main.name + target_id = "cloudwatch-logs" + arn = aws_cloudwatch_log_group.events.arn + + dead_letter_config { + arn = local.enable_dlq ? aws_sqs_queue.dlq[0].arn : null + } +} + +################################################################################ +# Schema Registry (Optional) +################################################################################ + +resource "aws_schemas_discoverer" "main" { + count = local.enable_schema_discovery ? 1 : 0 + source_arn = aws_cloudwatch_event_bus.main.arn + description = "Schema discoverer for ${local.bus_name}" + + tags = { Name = local.bus_name } +} + +################################################################################ +# CloudWatch Alarms +################################################################################ + +resource "aws_cloudwatch_metric_alarm" "dlq_messages" { + count = local.enable_dlq ? 1 : 0 + alarm_name = "${local.bus_name}-dlq-messages" + comparison_operator = "GreaterThanThreshold" + evaluation_periods = 1 + metric_name = "ApproximateNumberOfMessagesVisible" + namespace = "AWS/SQS" + period = 300 + statistic = "Sum" + threshold = 0 + alarm_description = "Events failing to deliver to targets" + + dimensions = { + QueueName = aws_sqs_queue.dlq[0].name + } + + tags = { Name = "${local.bus_name}-dlq-alarm" } +} + +resource "aws_cloudwatch_metric_alarm" "failed_invocations" { + alarm_name = "${local.bus_name}-failed-invocations" + comparison_operator = "GreaterThanThreshold" + evaluation_periods = 2 + metric_name = "FailedInvocations" + namespace = "AWS/Events" + period = 300 + statistic = "Sum" + threshold = 0 + alarm_description = "EventBridge rule invocations failing" + + dimensions = { + EventBusName = aws_cloudwatch_event_bus.main.name + } + + tags = { Name = "${local.bus_name}-failed-invocations" } +} + +################################################################################ +# Outputs +################################################################################ + +output "event_bus_name" { + value = aws_cloudwatch_event_bus.main.name +} + +output "event_bus_arn" { + value = aws_cloudwatch_event_bus.main.arn +} + +output "archive_arn" { + value = local.enable_archive ? aws_cloudwatch_event_archive.main[0].arn : null +} + +output "dlq_url" { + value = local.enable_dlq ? aws_sqs_queue.dlq[0].url : null +} + +output "dlq_arn" { + value = local.enable_dlq ? aws_sqs_queue.dlq[0].arn : null +} + +output "log_group" { + value = aws_cloudwatch_log_group.events.name +} + +output "rule_arns" { + value = { for k, v in aws_cloudwatch_event_rule.rules : k => v.arn } +} + +output "put_event_example" { + value = <<-EOF + aws events put-events --entries '[{ + "EventBusName": "${aws_cloudwatch_event_bus.main.name}", + "Source": "${local.tenant}.myservice", + "DetailType": "order.created", + "Detail": "{\"orderId\": \"12345\", \"amount\": 99.99}" + }]' + EOF + description = "Example command to put an event" +} diff --git a/terraform/05-workloads/_template/eventbridge-rules/main.tf b/terraform/05-workloads/_template/eventbridge-rules/main.tf new file mode 100644 index 0000000..1c517fe --- /dev/null +++ b/terraform/05-workloads/_template/eventbridge-rules/main.tf @@ -0,0 +1,422 @@ +################################################################################ +# Workload: EventBridge Rules +# +# Event-driven automation with: +# - Scheduled rules (cron/rate) +# - Event pattern rules (AWS service events) +# - Multiple targets (Lambda, SQS, SNS, Step Functions) +# - Dead letter queues +# - Input transformations +# +# Use cases: Scheduled jobs, event routing, service integration +################################################################################ + +terraform { + required_version = ">= 1.5" + + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 5.0" + } + } + + backend "s3" { + key = "05-workloads/--events/terraform.tfstate" + } +} + +################################################################################ +# Configuration - UPDATE THESE +################################################################################ + +locals { + # Naming + tenant = "" + name = "" + env = "prod" + + prefix = "${local.tenant}-${local.name}" + + # Use custom event bus (null = default bus) + event_bus_name = null + + # Scheduled rules + scheduled_rules = { + # Daily report at 9 AM UTC + daily-report = { + description = "Generate daily report" + schedule_expression = "cron(0 9 * * ? *)" + enabled = true + target_type = "lambda" + target_arn = "" # Lambda function ARN + input = jsonencode({ + report_type = "daily" + format = "pdf" + }) + } + + # Every 5 minutes health check + health-check = { + description = "Periodic health check" + schedule_expression = "rate(5 minutes)" + enabled = true + target_type = "lambda" + target_arn = "" # Lambda function ARN + } + + # Monthly cleanup (1st of month at midnight) + monthly-cleanup = { + description = "Monthly data cleanup" + schedule_expression = "cron(0 0 1 * ? *)" + enabled = true + target_type = "step-function" + target_arn = "" # State machine ARN + input = jsonencode({ + retention_days = 90 + }) + } + } + + # Event pattern rules (react to AWS events) + event_pattern_rules = { + # EC2 instance state changes + ec2-state-change = { + description = "EC2 instance state changes" + enabled = true + event_pattern = jsonencode({ + source = ["aws.ec2"] + detail-type = ["EC2 Instance State-change Notification"] + detail = { + state = ["stopped", "terminated"] + } + }) + target_type = "sns" + target_arn = "" # SNS topic ARN + } + + # S3 object created + s3-upload = { + description = "S3 object created in uploads bucket" + enabled = true + event_pattern = jsonencode({ + source = ["aws.s3"] + detail-type = ["Object Created"] + detail = { + bucket = { + name = ["my-uploads-bucket"] + } + } + }) + target_type = "lambda" + target_arn = "" # Lambda function ARN + input_transformer = { + input_paths = { + bucket = "$.detail.bucket.name" + key = "$.detail.object.key" + size = "$.detail.object.size" + } + input_template = <<-EOF + { + "bucket": , + "key": , + "size": , + "timestamp": "" + } + EOF + } + } + + # CodePipeline state change + pipeline-failed = { + description = "CodePipeline execution failed" + enabled = true + event_pattern = jsonencode({ + source = ["aws.codepipeline"] + detail-type = ["CodePipeline Pipeline Execution State Change"] + detail = { + state = ["FAILED"] + } + }) + target_type = "sns" + target_arn = "" # SNS topic ARN + } + + # GuardDuty findings + security-findings = { + description = "GuardDuty security findings" + enabled = false # Enable when GuardDuty is active + event_pattern = jsonencode({ + source = ["aws.guardduty"] + detail-type = ["GuardDuty Finding"] + detail = { + severity = [{ numeric = [">=", 7] }] # High severity + } + }) + target_type = "sns" + target_arn = "" # SNS topic ARN + } + } + + # Enable DLQ for failed deliveries + enable_dlq = true +} + +################################################################################ +# Variables +################################################################################ + +variable "region" { + type = string + default = "us-east-1" +} + +variable "state_bucket" { + type = string +} + +################################################################################ +# Provider +################################################################################ + +provider "aws" { + region = var.region + + default_tags { + tags = { + Tenant = local.tenant + App = local.name + Environment = local.env + ManagedBy = "terraform" + } + } +} + +################################################################################ +# Data Sources +################################################################################ + +data "aws_caller_identity" "current" {} +data "aws_region" "current" {} + +################################################################################ +# Dead Letter Queue +################################################################################ + +resource "aws_sqs_queue" "dlq" { + count = local.enable_dlq ? 1 : 0 + name = "${local.prefix}-events-dlq" + + message_retention_seconds = 1209600 # 14 days + kms_master_key_id = "alias/aws/sqs" + + tags = { Name = "${local.prefix}-events-dlq" } +} + +resource "aws_sqs_queue_policy" "dlq" { + count = local.enable_dlq ? 1 : 0 + queue_url = aws_sqs_queue.dlq[0].id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Sid = "AllowEventBridge" + Effect = "Allow" + Principal = { + Service = "events.amazonaws.com" + } + Action = "sqs:SendMessage" + Resource = aws_sqs_queue.dlq[0].arn + }] + }) +} + +################################################################################ +# IAM Role for EventBridge +################################################################################ + +resource "aws_iam_role" "eventbridge" { + name = "${local.prefix}-eventbridge" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Effect = "Allow" + Action = "sts:AssumeRole" + Principal = { Service = "events.amazonaws.com" } + }] + }) + + tags = { Name = "${local.prefix}-eventbridge" } +} + +resource "aws_iam_role_policy" "eventbridge" { + name = "invoke-targets" + role = aws_iam_role.eventbridge.id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Sid = "InvokeStepFunctions" + Effect = "Allow" + Action = "states:StartExecution" + Resource = "*" + }, + { + Sid = "InvokeLambda" + Effect = "Allow" + Action = "lambda:InvokeFunction" + Resource = "*" + }, + { + Sid = "SendToSQS" + Effect = "Allow" + Action = "sqs:SendMessage" + Resource = "*" + }, + { + Sid = "PublishToSNS" + Effect = "Allow" + Action = "sns:Publish" + Resource = "*" + } + ] + }) +} + +################################################################################ +# Scheduled Rules +################################################################################ + +resource "aws_cloudwatch_event_rule" "scheduled" { + for_each = { for k, v in local.scheduled_rules : k => v if v.target_arn != "" } + + name = "${local.prefix}-${each.key}" + description = lookup(each.value, "description", "Scheduled rule ${each.key}") + schedule_expression = each.value.schedule_expression + event_bus_name = local.event_bus_name + state = each.value.enabled ? "ENABLED" : "DISABLED" + + tags = { Name = "${local.prefix}-${each.key}" } +} + +resource "aws_cloudwatch_event_target" "scheduled" { + for_each = { for k, v in local.scheduled_rules : k => v if v.target_arn != "" } + + rule = aws_cloudwatch_event_rule.scheduled[each.key].name + event_bus_name = local.event_bus_name + target_id = each.key + arn = each.value.target_arn + role_arn = each.value.target_type == "step-function" ? aws_iam_role.eventbridge.arn : null + input = lookup(each.value, "input", null) + + dynamic "dead_letter_config" { + for_each = local.enable_dlq ? [1] : [] + content { + arn = aws_sqs_queue.dlq[0].arn + } + } +} + +################################################################################ +# Event Pattern Rules +################################################################################ + +resource "aws_cloudwatch_event_rule" "pattern" { + for_each = { for k, v in local.event_pattern_rules : k => v if v.target_arn != "" } + + name = "${local.prefix}-${each.key}" + description = lookup(each.value, "description", "Event pattern rule ${each.key}") + event_pattern = each.value.event_pattern + event_bus_name = local.event_bus_name + state = each.value.enabled ? "ENABLED" : "DISABLED" + + tags = { Name = "${local.prefix}-${each.key}" } +} + +resource "aws_cloudwatch_event_target" "pattern" { + for_each = { for k, v in local.event_pattern_rules : k => v if v.target_arn != "" } + + rule = aws_cloudwatch_event_rule.pattern[each.key].name + event_bus_name = local.event_bus_name + target_id = each.key + arn = each.value.target_arn + role_arn = each.value.target_type == "step-function" ? aws_iam_role.eventbridge.arn : null + input = lookup(each.value, "input", null) + + dynamic "input_transformer" { + for_each = lookup(each.value, "input_transformer", null) != null ? [each.value.input_transformer] : [] + content { + input_paths = input_transformer.value.input_paths + input_template = input_transformer.value.input_template + } + } + + dynamic "dead_letter_config" { + for_each = local.enable_dlq ? [1] : [] + content { + arn = aws_sqs_queue.dlq[0].arn + } + } +} + +################################################################################ +# Lambda Permissions +################################################################################ + +resource "aws_lambda_permission" "scheduled" { + for_each = { for k, v in local.scheduled_rules : k => v if v.target_arn != "" && v.target_type == "lambda" } + + statement_id = "AllowEventBridge-${each.key}" + action = "lambda:InvokeFunction" + function_name = regex("function:([^:]+)", each.value.target_arn)[0] + principal = "events.amazonaws.com" + source_arn = aws_cloudwatch_event_rule.scheduled[each.key].arn +} + +resource "aws_lambda_permission" "pattern" { + for_each = { for k, v in local.event_pattern_rules : k => v if v.target_arn != "" && v.target_type == "lambda" } + + statement_id = "AllowEventBridge-${each.key}" + action = "lambda:InvokeFunction" + function_name = regex("function:([^:]+)", each.value.target_arn)[0] + principal = "events.amazonaws.com" + source_arn = aws_cloudwatch_event_rule.pattern[each.key].arn +} + +################################################################################ +# Outputs +################################################################################ + +output "scheduled_rule_arns" { + value = { for k, v in aws_cloudwatch_event_rule.scheduled : k => v.arn } + description = "Scheduled rule ARNs" +} + +output "pattern_rule_arns" { + value = { for k, v in aws_cloudwatch_event_rule.pattern : k => v.arn } + description = "Event pattern rule ARNs" +} + +output "dlq_arn" { + value = local.enable_dlq ? aws_sqs_queue.dlq[0].arn : null + description = "Dead letter queue ARN" +} + +output "eventbridge_role_arn" { + value = aws_iam_role.eventbridge.arn + description = "EventBridge execution role ARN" +} + +output "cron_examples" { + value = { + every_5_min = "rate(5 minutes)" + every_hour = "rate(1 hour)" + daily_9am_utc = "cron(0 9 * * ? *)" + weekdays_8am = "cron(0 8 ? * MON-FRI *)" + monthly_1st = "cron(0 0 1 * ? *)" + every_monday = "cron(0 12 ? * MON *)" + } + description = "Cron expression examples" +} diff --git a/terraform/05-workloads/_template/lambda-function/main.tf b/terraform/05-workloads/_template/lambda-function/main.tf new file mode 100644 index 0000000..c2ba848 --- /dev/null +++ b/terraform/05-workloads/_template/lambda-function/main.tf @@ -0,0 +1,409 @@ +################################################################################ +# Workload: Lambda Function +# +# Deploys a serverless function: +# - Lambda function with VPC access (optional) +# - API Gateway HTTP API (optional) +# - CloudWatch logging & X-Ray tracing +# - EventBridge rules for scheduled invocation (optional) +# +# Usage: +# Copy this folder to 05-workloads/-/ +# Update locals and variables +# terraform init -backend-config=../../00-bootstrap/backend.hcl +# terraform apply +################################################################################ + +terraform { + required_version = ">= 1.5" + + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 5.0" + } + } + + backend "s3" { + key = "05-workloads/-/terraform.tfstate" + } +} + +################################################################################ +# Configuration - UPDATE THESE +################################################################################ + +locals { + # Naming + tenant = "" + app = "" + env = "prod" # prod, staging, dev + name = "${local.tenant}-${local.app}-${local.env}" + + # Lambda config + runtime = "python3.12" # python3.12, nodejs20.x, go1.x, etc. + handler = "main.handler" + memory_size = 256 + timeout = 30 + + # Source - provide ONE of these + source_dir = null # Path to source directory (will be zipped) + s3_bucket = null # S3 bucket containing deployment package + s3_key = null # S3 key for deployment package + image_uri = null # Container image URI + + # VPC - set to true for database access + enable_vpc = false + + # API Gateway + enable_api = true + api_path = "/{proxy+}" + + # Scheduled execution (cron or rate expression) + schedule_expression = null # "rate(5 minutes)" or "cron(0 12 * * ? *)" + + # Environment variables + environment = { + APP_ENV = local.env + LOG_LEVEL = "INFO" + } + + # Secrets (ARNs to SSM/Secrets Manager) + secrets = {} +} + +################################################################################ +# Variables +################################################################################ + +variable "region" { + type = string + default = "us-east-1" +} + +variable "state_bucket" { + type = string +} + +################################################################################ +# Provider +################################################################################ + +provider "aws" { + region = var.region + + default_tags { + tags = { + Tenant = local.tenant + App = local.app + Environment = local.env + ManagedBy = "terraform" + } + } +} + +################################################################################ +# Data Sources +################################################################################ + +data "terraform_remote_state" "network" { + count = local.enable_vpc ? 1 : 0 + backend = "s3" + config = { + bucket = var.state_bucket + key = "02-network/terraform.tfstate" + region = var.region + } +} + +data "terraform_remote_state" "tenant" { + count = local.enable_vpc ? 1 : 0 + backend = "s3" + config = { + bucket = var.state_bucket + key = "04-tenants/${local.tenant}/terraform.tfstate" + region = var.region + } +} + +data "aws_caller_identity" "current" {} +data "aws_region" "current" {} + +################################################################################ +# CloudWatch Log Group +################################################################################ + +resource "aws_cloudwatch_log_group" "main" { + name = "/aws/lambda/${local.name}" + retention_in_days = 30 + + tags = { Name = local.name } +} + +################################################################################ +# IAM Role +################################################################################ + +resource "aws_iam_role" "lambda" { + name = "${local.name}-lambda" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Effect = "Allow" + Action = "sts:AssumeRole" + Principal = { Service = "lambda.amazonaws.com" } + }] + }) + + tags = { Name = "${local.name}-lambda" } +} + +resource "aws_iam_role_policy_attachment" "lambda_basic" { + role = aws_iam_role.lambda.name + policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" +} + +resource "aws_iam_role_policy_attachment" "lambda_vpc" { + count = local.enable_vpc ? 1 : 0 + role = aws_iam_role.lambda.name + policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaVPCAccessExecutionRole" +} + +resource "aws_iam_role_policy_attachment" "lambda_xray" { + role = aws_iam_role.lambda.name + policy_arn = "arn:aws:iam::aws:policy/AWSXRayDaemonWriteAccess" +} + +resource "aws_iam_role_policy" "lambda_app" { + name = "app-permissions" + role = aws_iam_role.lambda.id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Sid = "AllowTaggedResources" + Effect = "Allow" + Action = ["s3:GetObject", "s3:PutObject", "dynamodb:*", "sqs:*", "sns:Publish"] + Resource = "*" + Condition = { StringEquals = { "aws:ResourceTag/Tenant" = local.tenant } } + }, + { + Sid = "SecretsAccess" + Effect = "Allow" + Action = ["secretsmanager:GetSecretValue", "ssm:GetParameter", "ssm:GetParameters"] + Resource = length(local.secrets) > 0 ? values(local.secrets) : ["arn:aws:ssm:${data.aws_region.current.name}:${data.aws_caller_identity.current.account_id}:parameter/${local.tenant}/*"] + } + ] + }) +} + +################################################################################ +# Security Group (VPC mode) +################################################################################ + +resource "aws_security_group" "lambda" { + count = local.enable_vpc ? 1 : 0 + name = "${local.name}-lambda" + description = "Lambda function ${local.name}" + vpc_id = data.terraform_remote_state.network[0].outputs.vpc_id + + egress { + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + } + + tags = { Name = "${local.name}-lambda" } +} + +################################################################################ +# Lambda Function +################################################################################ + +# Create zip from source directory if provided +data "archive_file" "lambda" { + count = local.source_dir != null ? 1 : 0 + type = "zip" + source_dir = local.source_dir + output_path = "${path.module}/lambda.zip" +} + +resource "aws_lambda_function" "main" { + function_name = local.name + description = "${local.tenant} ${local.app} function" + role = aws_iam_role.lambda.arn + + # Source - exactly one must be specified + filename = local.source_dir != null ? data.archive_file.lambda[0].output_path : null + source_code_hash = local.source_dir != null ? data.archive_file.lambda[0].output_base64sha256 : null + s3_bucket = local.s3_bucket + s3_key = local.s3_key + image_uri = local.image_uri + package_type = local.image_uri != null ? "Image" : "Zip" + + # Only for Zip packages + runtime = local.image_uri == null ? local.runtime : null + handler = local.image_uri == null ? local.handler : null + + memory_size = local.memory_size + timeout = local.timeout + + environment { + variables = merge(local.environment, { + for k, v in local.secrets : k => v + }) + } + + dynamic "vpc_config" { + for_each = local.enable_vpc ? [1] : [] + content { + subnet_ids = data.terraform_remote_state.network[0].outputs.private_subnet_ids + security_group_ids = [ + aws_security_group.lambda[0].id, + data.terraform_remote_state.tenant[0].outputs.security_groups.base + ] + } + } + + tracing_config { + mode = "Active" + } + + depends_on = [aws_cloudwatch_log_group.main] + + tags = { Name = local.name } +} + +################################################################################ +# API Gateway HTTP API +################################################################################ + +resource "aws_apigatewayv2_api" "main" { + count = local.enable_api ? 1 : 0 + name = local.name + protocol_type = "HTTP" + + cors_configuration { + allow_origins = ["*"] + allow_methods = ["GET", "POST", "PUT", "DELETE", "OPTIONS"] + allow_headers = ["Content-Type", "Authorization"] + max_age = 300 + } + + tags = { Name = local.name } +} + +resource "aws_apigatewayv2_stage" "main" { + count = local.enable_api ? 1 : 0 + api_id = aws_apigatewayv2_api.main[0].id + name = "$default" + auto_deploy = true + + access_log_settings { + destination_arn = aws_cloudwatch_log_group.api[0].arn + format = jsonencode({ + requestId = "$context.requestId" + ip = "$context.identity.sourceIp" + requestTime = "$context.requestTime" + httpMethod = "$context.httpMethod" + routeKey = "$context.routeKey" + status = "$context.status" + responseLength = "$context.responseLength" + integrationError = "$context.integrationErrorMessage" + }) + } + + tags = { Name = local.name } +} + +resource "aws_cloudwatch_log_group" "api" { + count = local.enable_api ? 1 : 0 + name = "/aws/apigateway/${local.name}" + retention_in_days = 30 + + tags = { Name = "${local.name}-api" } +} + +resource "aws_apigatewayv2_integration" "main" { + count = local.enable_api ? 1 : 0 + api_id = aws_apigatewayv2_api.main[0].id + integration_type = "AWS_PROXY" + integration_uri = aws_lambda_function.main.invoke_arn + payload_format_version = "2.0" +} + +resource "aws_apigatewayv2_route" "main" { + count = local.enable_api ? 1 : 0 + api_id = aws_apigatewayv2_api.main[0].id + route_key = "ANY ${local.api_path}" + target = "integrations/${aws_apigatewayv2_integration.main[0].id}" +} + +resource "aws_lambda_permission" "api" { + count = local.enable_api ? 1 : 0 + statement_id = "AllowAPIGateway" + action = "lambda:InvokeFunction" + function_name = aws_lambda_function.main.function_name + principal = "apigateway.amazonaws.com" + source_arn = "${aws_apigatewayv2_api.main[0].execution_arn}/*/*" +} + +################################################################################ +# EventBridge Schedule +################################################################################ + +resource "aws_cloudwatch_event_rule" "schedule" { + count = local.schedule_expression != null ? 1 : 0 + name = "${local.name}-schedule" + description = "Schedule for ${local.name}" + schedule_expression = local.schedule_expression + + tags = { Name = "${local.name}-schedule" } +} + +resource "aws_cloudwatch_event_target" "schedule" { + count = local.schedule_expression != null ? 1 : 0 + rule = aws_cloudwatch_event_rule.schedule[0].name + target_id = "lambda" + arn = aws_lambda_function.main.arn +} + +resource "aws_lambda_permission" "schedule" { + count = local.schedule_expression != null ? 1 : 0 + statement_id = "AllowEventBridge" + action = "lambda:InvokeFunction" + function_name = aws_lambda_function.main.function_name + principal = "events.amazonaws.com" + source_arn = aws_cloudwatch_event_rule.schedule[0].arn +} + +################################################################################ +# Outputs +################################################################################ + +output "function_name" { + value = aws_lambda_function.main.function_name +} + +output "function_arn" { + value = aws_lambda_function.main.arn +} + +output "invoke_arn" { + value = aws_lambda_function.main.invoke_arn +} + +output "api_endpoint" { + value = local.enable_api ? aws_apigatewayv2_api.main[0].api_endpoint : null +} + +output "log_group" { + value = aws_cloudwatch_log_group.main.name +} + +output "role_arn" { + value = aws_iam_role.lambda.arn +} diff --git a/terraform/05-workloads/_template/opensearch/main.tf b/terraform/05-workloads/_template/opensearch/main.tf new file mode 100644 index 0000000..02ff010 --- /dev/null +++ b/terraform/05-workloads/_template/opensearch/main.tf @@ -0,0 +1,458 @@ +################################################################################ +# Workload: OpenSearch (Elasticsearch) +# +# Search and analytics with: +# - Serverless or provisioned clusters +# - Fine-grained access control +# - VPC or public access +# - Cognito authentication +# - UltraWarm for cost-effective storage +# - Cross-cluster search +# +# Use cases: Log analytics, full-text search, observability +################################################################################ + +terraform { + required_version = ">= 1.5" + + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 5.0" + } + } + + backend "s3" { + key = "05-workloads/--opensearch/terraform.tfstate" + } +} + +################################################################################ +# Configuration - UPDATE THESE +################################################################################ + +locals { + # Naming + tenant = "" + name = "" + env = "prod" + + domain_name = "${local.tenant}-${local.name}-${local.env}" + + # Engine + engine_version = "OpenSearch_2.11" + + # Cluster sizing + cluster = { + # Data nodes + instance_type = "t3.medium.search" # t3.small.search for dev + instance_count = 2 + + # Dedicated master nodes (recommended for production) + dedicated_master_enabled = local.env == "prod" + dedicated_master_type = "t3.medium.search" + dedicated_master_count = 3 + + # Multi-AZ + zone_awareness_enabled = local.env == "prod" + availability_zone_count = local.env == "prod" ? 2 : 1 + } + + # Storage + storage = { + type = "gp3" + size_gb = 100 + iops = 3000 + throughput = 125 + } + + # UltraWarm (cost-effective warm storage) + ultrawarm = { + enabled = false + type = "ultrawarm1.medium.search" + count = 2 + } + + # Network + # Option 1: VPC (private, more secure) + vpc_enabled = true + vpc_id = "" # data.terraform_remote_state.network.outputs.vpc_id + private_subnet_ids = [] # data.terraform_remote_state.network.outputs.private_subnet_ids + + # Option 2: Public (set vpc_enabled = false) + # Uses IP-based access policy + + # Access control + enable_fine_grained_access = true + master_user_name = "admin" + + # Cognito authentication (optional, for Dashboards) + cognito = { + enabled = false + user_pool_id = "" + identity_pool_id = "" + role_arn = "" + } + + # Encryption + encrypt_at_rest = true + node_to_node_encryption = true + + # Logging + log_types = ["INDEX_SLOW_LOGS", "SEARCH_SLOW_LOGS", "ES_APPLICATION_LOGS"] + + # Auto-tune + auto_tune_enabled = true +} + +################################################################################ +# Variables +################################################################################ + +variable "region" { + type = string + default = "us-east-1" +} + +variable "state_bucket" { + type = string +} + +################################################################################ +# Provider +################################################################################ + +provider "aws" { + region = var.region + + default_tags { + tags = { + Tenant = local.tenant + App = local.name + Environment = local.env + ManagedBy = "terraform" + } + } +} + +################################################################################ +# Data Sources +################################################################################ + +data "aws_caller_identity" "current" {} +data "aws_region" "current" {} + +################################################################################ +# Random Password for Master User +################################################################################ + +resource "random_password" "master" { + count = local.enable_fine_grained_access ? 1 : 0 + length = 24 + special = true + override_special = "!#$%&*()-_=+[]{}<>:?" +} + +################################################################################ +# Secrets Manager +################################################################################ + +resource "aws_secretsmanager_secret" "opensearch" { + count = local.enable_fine_grained_access ? 1 : 0 + name = "${local.tenant}/${local.env}/${local.name}/opensearch" + description = "OpenSearch master credentials" + + tags = { Name = "${local.domain_name}-credentials" } +} + +resource "aws_secretsmanager_secret_version" "opensearch" { + count = local.enable_fine_grained_access ? 1 : 0 + secret_id = aws_secretsmanager_secret.opensearch[0].id + secret_string = jsonencode({ + username = local.master_user_name + password = random_password.master[0].result + endpoint = aws_opensearch_domain.main.endpoint + }) +} + +################################################################################ +# CloudWatch Log Groups +################################################################################ + +resource "aws_cloudwatch_log_group" "opensearch" { + for_each = toset(local.log_types) + name = "/aws/opensearch/${local.domain_name}/${lower(each.key)}" + retention_in_days = 30 + + tags = { Name = "${local.domain_name}-${lower(each.key)}" } +} + +resource "aws_cloudwatch_log_resource_policy" "opensearch" { + policy_name = "${local.domain_name}-logs" + + policy_document = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Effect = "Allow" + Principal = { + Service = "es.amazonaws.com" + } + Action = [ + "logs:PutLogEvents", + "logs:CreateLogStream" + ] + Resource = "arn:aws:logs:${data.aws_region.current.name}:${data.aws_caller_identity.current.account_id}:log-group:/aws/opensearch/${local.domain_name}/*" + }] + }) +} + +################################################################################ +# Security Group (VPC mode) +################################################################################ + +resource "aws_security_group" "opensearch" { + count = local.vpc_enabled && length(local.vpc_id) > 0 ? 1 : 0 + name = "${local.domain_name}-opensearch" + vpc_id = local.vpc_id + + ingress { + description = "HTTPS from VPC" + from_port = 443 + to_port = 443 + protocol = "tcp" + cidr_blocks = ["10.0.0.0/8"] + } + + egress { + description = "All outbound" + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + } + + tags = { Name = "${local.domain_name}-opensearch" } +} + +################################################################################ +# IAM Service-Linked Role +################################################################################ + +resource "aws_iam_service_linked_role" "opensearch" { + count = local.vpc_enabled ? 1 : 0 + aws_service_name = "opensearchservice.amazonaws.com" +} + +################################################################################ +# OpenSearch Domain +################################################################################ + +resource "aws_opensearch_domain" "main" { + domain_name = local.domain_name + engine_version = local.engine_version + + # Cluster configuration + cluster_config { + instance_type = local.cluster.instance_type + instance_count = local.cluster.instance_count + + dedicated_master_enabled = local.cluster.dedicated_master_enabled + dedicated_master_type = local.cluster.dedicated_master_enabled ? local.cluster.dedicated_master_type : null + dedicated_master_count = local.cluster.dedicated_master_enabled ? local.cluster.dedicated_master_count : null + + zone_awareness_enabled = local.cluster.zone_awareness_enabled + + dynamic "zone_awareness_config" { + for_each = local.cluster.zone_awareness_enabled ? [1] : [] + content { + availability_zone_count = local.cluster.availability_zone_count + } + } + + # UltraWarm + warm_enabled = local.ultrawarm.enabled + warm_type = local.ultrawarm.enabled ? local.ultrawarm.type : null + warm_count = local.ultrawarm.enabled ? local.ultrawarm.count : null + } + + # Storage + ebs_options { + ebs_enabled = true + volume_type = local.storage.type + volume_size = local.storage.size_gb + iops = local.storage.type == "gp3" ? local.storage.iops : null + throughput = local.storage.type == "gp3" ? local.storage.throughput : null + } + + # VPC configuration + dynamic "vpc_options" { + for_each = local.vpc_enabled && length(local.private_subnet_ids) > 0 ? [1] : [] + content { + subnet_ids = slice(local.private_subnet_ids, 0, local.cluster.availability_zone_count) + security_group_ids = [aws_security_group.opensearch[0].id] + } + } + + # Encryption + encrypt_at_rest { + enabled = local.encrypt_at_rest + } + + node_to_node_encryption { + enabled = local.node_to_node_encryption + } + + domain_endpoint_options { + enforce_https = true + tls_security_policy = "Policy-Min-TLS-1-2-2019-07" + } + + # Fine-grained access control + advanced_security_options { + enabled = local.enable_fine_grained_access + internal_user_database_enabled = local.enable_fine_grained_access + + dynamic "master_user_options" { + for_each = local.enable_fine_grained_access ? [1] : [] + content { + master_user_name = local.master_user_name + master_user_password = random_password.master[0].result + } + } + } + + # Cognito authentication + dynamic "cognito_options" { + for_each = local.cognito.enabled ? [1] : [] + content { + enabled = true + user_pool_id = local.cognito.user_pool_id + identity_pool_id = local.cognito.identity_pool_id + role_arn = local.cognito.role_arn + } + } + + # Logging + dynamic "log_publishing_options" { + for_each = local.log_types + content { + cloudwatch_log_group_arn = aws_cloudwatch_log_group.opensearch[log_publishing_options.value].arn + log_type = log_publishing_options.value + } + } + + # Auto-tune + auto_tune_options { + desired_state = local.auto_tune_enabled ? "ENABLED" : "DISABLED" + rollback_on_disable = "NO_ROLLBACK" + } + + # Access policy (for non-VPC or fine-grained access) + access_policies = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Effect = "Allow" + Principal = { + AWS = "*" + } + Action = "es:*" + Resource = "arn:aws:es:${data.aws_region.current.name}:${data.aws_caller_identity.current.account_id}:domain/${local.domain_name}/*" + Condition = local.vpc_enabled ? {} : { + IpAddress = { + "aws:SourceIp" = ["0.0.0.0/0"] # Restrict in production! + } + } + } + ] + }) + + tags = { Name = local.domain_name } + + depends_on = [ + aws_iam_service_linked_role.opensearch, + aws_cloudwatch_log_resource_policy.opensearch + ] +} + +################################################################################ +# IAM Policy for Application Access +################################################################################ + +resource "aws_iam_policy" "opensearch_access" { + name = "${local.domain_name}-access" + description = "Access to ${local.domain_name} OpenSearch domain" + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Sid = "OpenSearchAccess" + Effect = "Allow" + Action = [ + "es:ESHttpGet", + "es:ESHttpHead", + "es:ESHttpPost", + "es:ESHttpPut", + "es:ESHttpDelete" + ] + Resource = "${aws_opensearch_domain.main.arn}/*" + } + ] + }) + + tags = { Name = "${local.domain_name}-access" } +} + +################################################################################ +# Outputs +################################################################################ + +output "domain_endpoint" { + value = aws_opensearch_domain.main.endpoint + description = "OpenSearch domain endpoint" +} + +output "dashboard_endpoint" { + value = aws_opensearch_domain.main.dashboard_endpoint + description = "OpenSearch Dashboards endpoint" +} + +output "domain_arn" { + value = aws_opensearch_domain.main.arn + description = "Domain ARN" +} + +output "domain_id" { + value = aws_opensearch_domain.main.domain_id + description = "Domain ID" +} + +output "secret_arn" { + value = length(aws_secretsmanager_secret.opensearch) > 0 ? aws_secretsmanager_secret.opensearch[0].arn : null + description = "Secrets Manager ARN for credentials" +} + +output "access_policy_arn" { + value = aws_iam_policy.opensearch_access.arn + description = "IAM policy for application access" +} + +output "kibana_url" { + value = "https://${aws_opensearch_domain.main.dashboard_endpoint}/_dashboards" + description = "OpenSearch Dashboards URL" +} + +output "curl_example" { + value = local.enable_fine_grained_access ? <<-EOF + # Get credentials from Secrets Manager + SECRET=$(aws secretsmanager get-secret-value --secret-id ${aws_secretsmanager_secret.opensearch[0].arn} --query SecretString --output text) + USER=$(echo $SECRET | jq -r .username) + PASS=$(echo $SECRET | jq -r .password) + + # Query cluster health + curl -u "$USER:$PASS" "https://${aws_opensearch_domain.main.endpoint}/_cluster/health?pretty" + EOF + : null + description = "Example curl commands" +} diff --git a/terraform/05-workloads/_template/rds-database/main.tf b/terraform/05-workloads/_template/rds-database/main.tf new file mode 100644 index 0000000..bc34c43 --- /dev/null +++ b/terraform/05-workloads/_template/rds-database/main.tf @@ -0,0 +1,544 @@ +################################################################################ +# Workload: RDS Database +# +# Deploys a managed database: +# - RDS PostgreSQL/MySQL instance or Aurora cluster +# - Subnet group and security group +# - Parameter group with optimized settings +# - Secrets Manager for credentials +# - Optional read replica +# +# Usage: +# Copy this folder to 05-workloads/--db/ +# Update locals and variables +# terraform init -backend-config=../../00-bootstrap/backend.hcl +# terraform apply +################################################################################ + +terraform { + required_version = ">= 1.5" + + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 5.0" + } + random = { + source = "hashicorp/random" + version = ">= 3.0" + } + } + + backend "s3" { + key = "05-workloads/--db/terraform.tfstate" + } +} + +################################################################################ +# Configuration - UPDATE THESE +################################################################################ + +locals { + # Naming + tenant = "" + app = "" + env = "prod" # prod, staging, dev + name = "${local.tenant}-${local.app}-${local.env}" + + # Engine - "postgres", "mysql", "aurora-postgresql", "aurora-mysql" + engine = "postgres" + engine_version = "16.3" + + # Instance sizing + instance_class = "db.t3.micro" # db.t3.micro, db.t3.small, db.r6g.large, etc. + storage_gb = 20 + max_storage_gb = 100 # Auto-scaling max (set to storage_gb to disable) + + # High availability + multi_az = false # true for prod + read_replica = false # Create read replica + + # Database config + database_name = "app" + port = 5432 # 5432 for postgres, 3306 for mysql + + # Backup + backup_retention_days = 7 + backup_window = "03:00-04:00" # UTC + maintenance_window = "sun:04:00-sun:05:00" + + # Deletion protection (disable for dev/test) + deletion_protection = local.env == "prod" + skip_final_snapshot = local.env != "prod" + + # Performance Insights (free for 7 days retention) + performance_insights = true + + # IAM database authentication (recommended for apps) + iam_auth_enabled = true + + # Enhanced Monitoring interval (0 to disable, 1/5/10/15/30/60 seconds) + monitoring_interval = local.env == "prod" ? 60 : 0 + + # Is this an Aurora cluster? + is_aurora = startswith(local.engine, "aurora-") +} + +################################################################################ +# Variables +################################################################################ + +variable "region" { + type = string + default = "us-east-1" +} + +variable "state_bucket" { + type = string +} + +################################################################################ +# Provider +################################################################################ + +provider "aws" { + region = var.region + + default_tags { + tags = { + Tenant = local.tenant + App = local.app + Environment = local.env + ManagedBy = "terraform" + } + } +} + +################################################################################ +# Data Sources +################################################################################ + +data "terraform_remote_state" "network" { + backend = "s3" + config = { + bucket = var.state_bucket + key = "02-network/terraform.tfstate" + region = var.region + } +} + +data "terraform_remote_state" "tenant" { + backend = "s3" + config = { + bucket = var.state_bucket + key = "04-tenants/${local.tenant}/terraform.tfstate" + region = var.region + } +} + +data "aws_caller_identity" "current" {} +data "aws_region" "current" {} + +################################################################################ +# KMS Key for Encryption +################################################################################ + +resource "aws_kms_key" "db" { + description = "KMS key for ${local.name} database encryption" + deletion_window_in_days = 7 + enable_key_rotation = true + + tags = { Name = "${local.name}-db" } +} + +resource "aws_kms_alias" "db" { + name = "alias/${local.name}-db" + target_key_id = aws_kms_key.db.key_id +} + +################################################################################ +# Random Password +################################################################################ + +resource "random_password" "master" { + length = 32 + special = true + override_special = "!#$%&*()-_=+[]{}<>:?" +} + +################################################################################ +# Secrets Manager +################################################################################ + +resource "aws_secretsmanager_secret" "db" { + name = "${local.name}-db-credentials" + description = "Database credentials for ${local.name}" + recovery_window_in_days = local.env == "prod" ? 30 : 0 + + tags = { Name = "${local.name}-db-credentials" } +} + +resource "aws_secretsmanager_secret_version" "db" { + secret_id = aws_secretsmanager_secret.db.id + secret_string = jsonencode({ + username = "dbadmin" + password = random_password.master.result + engine = local.engine + host = local.is_aurora ? aws_rds_cluster.main[0].endpoint : aws_db_instance.main[0].address + port = local.port + database = local.database_name + url = local.is_aurora ? "postgresql://dbadmin:${random_password.master.result}@${aws_rds_cluster.main[0].endpoint}:${local.port}/${local.database_name}" : "postgresql://dbadmin:${random_password.master.result}@${aws_db_instance.main[0].address}:${local.port}/${local.database_name}" + }) + + depends_on = [aws_db_instance.main, aws_rds_cluster.main] +} + +################################################################################ +# Subnet Group +################################################################################ + +resource "aws_db_subnet_group" "main" { + name = local.name + description = "Subnet group for ${local.name}" + subnet_ids = data.terraform_remote_state.network.outputs.private_subnet_ids + + tags = { Name = local.name } +} + +################################################################################ +# Security Group +################################################################################ + +resource "aws_security_group" "db" { + name = "${local.name}-db" + description = "Database ${local.name}" + vpc_id = data.terraform_remote_state.network.outputs.vpc_id + + ingress { + description = "Database port from tenant" + from_port = local.port + to_port = local.port + protocol = "tcp" + security_groups = [data.terraform_remote_state.tenant.outputs.security_groups.base] + } + + egress { + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + } + + tags = { Name = "${local.name}-db" } +} + +################################################################################ +# Parameter Group +################################################################################ + +resource "aws_db_parameter_group" "main" { + count = local.is_aurora ? 0 : 1 + name = local.name + family = "${local.engine}${split(".", local.engine_version)[0]}" + + dynamic "parameter" { + for_each = local.engine == "postgres" ? [ + { name = "log_statement", value = "ddl" }, + { name = "log_min_duration_statement", value = "1000" }, + { name = "shared_preload_libraries", value = "pg_stat_statements", apply = "pending-reboot" } + ] : [ + { name = "slow_query_log", value = "1" }, + { name = "long_query_time", value = "1" } + ] + content { + name = parameter.value.name + value = parameter.value.value + apply_method = lookup(parameter.value, "apply", "immediate") + } + } + + tags = { Name = local.name } +} + +resource "aws_rds_cluster_parameter_group" "main" { + count = local.is_aurora ? 1 : 0 + name = local.name + family = local.engine == "aurora-postgresql" ? "aurora-postgresql16" : "aurora-mysql8.0" + + dynamic "parameter" { + for_each = local.engine == "aurora-postgresql" ? [ + { name = "log_statement", value = "ddl" }, + { name = "log_min_duration_statement", value = "1000" } + ] : [ + { name = "slow_query_log", value = "1" }, + { name = "long_query_time", value = "1" } + ] + content { + name = parameter.value.name + value = parameter.value.value + apply_method = lookup(parameter.value, "apply", "immediate") + } + } + + tags = { Name = local.name } +} + +################################################################################ +# RDS Instance (non-Aurora) +################################################################################ + +resource "aws_db_instance" "main" { + count = local.is_aurora ? 0 : 1 + + identifier = local.name + + engine = local.engine + engine_version = local.engine_version + instance_class = local.instance_class + + allocated_storage = local.storage_gb + max_allocated_storage = local.max_storage_gb + storage_type = "gp3" + storage_encrypted = true + kms_key_id = aws_kms_key.db.arn + + db_name = local.database_name + username = "dbadmin" + password = random_password.master.result + port = local.port + + multi_az = local.multi_az + db_subnet_group_name = aws_db_subnet_group.main.name + vpc_security_group_ids = [aws_security_group.db.id] + parameter_group_name = aws_db_parameter_group.main[0].name + publicly_accessible = false + + # IAM authentication for better security + iam_database_authentication_enabled = local.iam_auth_enabled + + backup_retention_period = local.backup_retention_days + backup_window = local.backup_window + maintenance_window = local.maintenance_window + copy_tags_to_snapshot = true + + performance_insights_enabled = local.performance_insights + performance_insights_retention_period = local.performance_insights ? 7 : null + performance_insights_kms_key_id = local.performance_insights ? aws_kms_key.db.arn : null + + # Enhanced monitoring + monitoring_interval = local.monitoring_interval + monitoring_role_arn = local.monitoring_interval > 0 ? aws_iam_role.rds_monitoring[0].arn : null + + deletion_protection = local.deletion_protection + skip_final_snapshot = local.skip_final_snapshot + final_snapshot_identifier = local.skip_final_snapshot ? null : "${local.name}-final" + + enabled_cloudwatch_logs_exports = local.engine == "postgres" ? ["postgresql", "upgrade"] : ["error", "slowquery"] + + # Require TLS connections + ca_cert_identifier = "rds-ca-rsa2048-g1" + + tags = { Name = local.name } +} + +################################################################################ +# RDS Read Replica (non-Aurora) +################################################################################ + +resource "aws_db_instance" "replica" { + count = !local.is_aurora && local.read_replica ? 1 : 0 + + identifier = "${local.name}-replica" + replicate_source_db = aws_db_instance.main[0].identifier + + instance_class = local.instance_class + + vpc_security_group_ids = [aws_security_group.db.id] + parameter_group_name = aws_db_parameter_group.main[0].name + publicly_accessible = false + + performance_insights_enabled = local.performance_insights + performance_insights_retention_period = local.performance_insights ? 7 : null + + skip_final_snapshot = true + + tags = { Name = "${local.name}-replica" } +} + +################################################################################ +# Aurora Cluster +################################################################################ + +resource "aws_rds_cluster" "main" { + count = local.is_aurora ? 1 : 0 + + cluster_identifier = local.name + + engine = local.engine + engine_version = local.engine_version + + database_name = local.database_name + master_username = "dbadmin" + master_password = random_password.master.result + port = local.port + + db_subnet_group_name = aws_db_subnet_group.main.name + vpc_security_group_ids = [aws_security_group.db.id] + db_cluster_parameter_group_name = aws_rds_cluster_parameter_group.main[0].name + + storage_encrypted = true + kms_key_id = aws_kms_key.db.arn + + # IAM authentication + iam_database_authentication_enabled = local.iam_auth_enabled + + backup_retention_period = local.backup_retention_days + preferred_backup_window = local.backup_window + preferred_maintenance_window = local.maintenance_window + copy_tags_to_snapshot = true + + deletion_protection = local.deletion_protection + skip_final_snapshot = local.skip_final_snapshot + final_snapshot_identifier = local.skip_final_snapshot ? null : "${local.name}-final" + + enabled_cloudwatch_logs_exports = local.engine == "aurora-postgresql" ? ["postgresql"] : ["error", "slowquery"] + + tags = { Name = local.name } +} + +resource "aws_rds_cluster_instance" "main" { + count = local.is_aurora ? (local.multi_az ? 2 : 1) : 0 + + identifier = "${local.name}-${count.index}" + cluster_identifier = aws_rds_cluster.main[0].id + + engine = aws_rds_cluster.main[0].engine + engine_version = aws_rds_cluster.main[0].engine_version + instance_class = local.instance_class + + publicly_accessible = false + + performance_insights_enabled = local.performance_insights + performance_insights_retention_period = local.performance_insights ? 7 : null + + tags = { Name = "${local.name}-${count.index}" } +} + +################################################################################ +# IAM Role for Enhanced Monitoring +################################################################################ + +resource "aws_iam_role" "rds_monitoring" { + count = local.monitoring_interval > 0 ? 1 : 0 + name = "${local.name}-rds-monitoring" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Effect = "Allow" + Action = "sts:AssumeRole" + Principal = { Service = "monitoring.rds.amazonaws.com" } + }] + }) + + tags = { Name = "${local.name}-rds-monitoring" } +} + +resource "aws_iam_role_policy_attachment" "rds_monitoring" { + count = local.monitoring_interval > 0 ? 1 : 0 + role = aws_iam_role.rds_monitoring[0].name + policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonRDSEnhancedMonitoringRole" +} + +################################################################################ +# CloudWatch Alarms +################################################################################ + +resource "aws_cloudwatch_metric_alarm" "cpu" { + alarm_name = "${local.name}-cpu-high" + comparison_operator = "GreaterThanThreshold" + evaluation_periods = 3 + metric_name = "CPUUtilization" + namespace = "AWS/RDS" + period = 300 + statistic = "Average" + threshold = 80 + alarm_description = "Database CPU utilization high" + + dimensions = { + DBInstanceIdentifier = local.is_aurora ? aws_rds_cluster_instance.main[0].identifier : aws_db_instance.main[0].identifier + } + + tags = { Name = "${local.name}-cpu-high" } +} + +resource "aws_cloudwatch_metric_alarm" "storage" { + count = local.is_aurora ? 0 : 1 + + alarm_name = "${local.name}-storage-low" + comparison_operator = "LessThanThreshold" + evaluation_periods = 1 + metric_name = "FreeStorageSpace" + namespace = "AWS/RDS" + period = 300 + statistic = "Average" + threshold = 5368709120 # 5 GB + alarm_description = "Database free storage space low" + + dimensions = { + DBInstanceIdentifier = aws_db_instance.main[0].identifier + } + + tags = { Name = "${local.name}-storage-low" } +} + +resource "aws_cloudwatch_metric_alarm" "connections" { + alarm_name = "${local.name}-connections-high" + comparison_operator = "GreaterThanThreshold" + evaluation_periods = 2 + metric_name = "DatabaseConnections" + namespace = "AWS/RDS" + period = 300 + statistic = "Average" + threshold = 50 # Adjust based on instance class + alarm_description = "Database connections high" + + dimensions = { + DBInstanceIdentifier = local.is_aurora ? aws_rds_cluster_instance.main[0].identifier : aws_db_instance.main[0].identifier + } + + tags = { Name = "${local.name}-connections-high" } +} + +################################################################################ +# Outputs +################################################################################ + +output "endpoint" { + value = local.is_aurora ? aws_rds_cluster.main[0].endpoint : aws_db_instance.main[0].address +} + +output "reader_endpoint" { + value = local.is_aurora ? aws_rds_cluster.main[0].reader_endpoint : (local.read_replica ? aws_db_instance.replica[0].address : null) +} + +output "port" { + value = local.port +} + +output "database_name" { + value = local.database_name +} + +output "secret_arn" { + value = aws_secretsmanager_secret.db.arn +} + +output "security_group_id" { + value = aws_security_group.db.id +} + +output "connection_string_ssm" { + value = "Retrieve from: aws secretsmanager get-secret-value --secret-id ${aws_secretsmanager_secret.db.name}" + description = "Command to retrieve connection string" +} diff --git a/terraform/05-workloads/_template/s3-bucket/main.tf b/terraform/05-workloads/_template/s3-bucket/main.tf new file mode 100644 index 0000000..3f8c3cd --- /dev/null +++ b/terraform/05-workloads/_template/s3-bucket/main.tf @@ -0,0 +1,527 @@ +################################################################################ +# Workload: S3 Bucket +# +# Multi-purpose S3 bucket with: +# - Versioning, encryption (KMS or S3) +# - Lifecycle rules (tiering, expiration) +# - Replication (cross-region DR) +# - Access logging +# - Event notifications (Lambda, SQS, SNS) +# - Object Lock (compliance/governance) +# +# Use cases: Data lake, backups, artifacts, logs, media storage +################################################################################ + +terraform { + required_version = ">= 1.5" + + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 5.0" + } + } + + backend "s3" { + key = "05-workloads/--bucket/terraform.tfstate" + } +} + +################################################################################ +# Configuration - UPDATE THESE +################################################################################ + +locals { + # Naming + tenant = "" + name = "" + env = "prod" + + bucket_name = "${local.tenant}-${local.name}-${local.env}-${data.aws_caller_identity.current.account_id}" + + # Versioning + versioning_enabled = true + + # Encryption + encryption_type = "SSE-S3" # SSE-S3, SSE-KMS, or KMS ARN + kms_key_arn = null # Set if using SSE-KMS + + # Public access (always blocked by default) + block_public_access = true + + # Access logging + enable_logging = true + logging_bucket = null # Set to existing logging bucket, or creates one + logging_prefix = "s3-access-logs/${local.bucket_name}/" + + # Lifecycle rules + lifecycle_rules = { + transition-to-ia = { + enabled = true + filter = { + prefix = "" + } + transitions = [ + { + days = 30 + storage_class = "STANDARD_IA" + }, + { + days = 90 + storage_class = "GLACIER" + } + ] + expiration_days = 365 + noncurrent_version_expiration_days = 90 + } + } + + # Cross-region replication + enable_replication = false + replication_region = "us-west-2" + replication_bucket = null # Will create if null + + # Event notifications + lambda_notifications = { + # "object-created" = { + # lambda_arn = "arn:aws:lambda:..." + # events = ["s3:ObjectCreated:*"] + # prefix = "uploads/" + # suffix = ".jpg" + # } + } + + sqs_notifications = { + # "new-files" = { + # queue_arn = "arn:aws:sqs:..." + # events = ["s3:ObjectCreated:*"] + # } + } + + # Object Lock (for compliance - cannot be disabled once enabled) + object_lock_enabled = false + object_lock_mode = "GOVERNANCE" # GOVERNANCE or COMPLIANCE + object_lock_days = 30 + + # CORS (for web access) + cors_enabled = false + cors_rules = [ + { + allowed_headers = ["*"] + allowed_methods = ["GET", "HEAD"] + allowed_origins = ["*"] + max_age_seconds = 3600 + } + ] + + # Intelligent tiering + intelligent_tiering_enabled = false +} + +################################################################################ +# Variables +################################################################################ + +variable "region" { + type = string + default = "us-east-1" +} + +variable "state_bucket" { + type = string +} + +################################################################################ +# Provider +################################################################################ + +provider "aws" { + region = var.region + + default_tags { + tags = { + Tenant = local.tenant + App = local.name + Environment = local.env + ManagedBy = "terraform" + } + } +} + +provider "aws" { + alias = "replication" + region = local.replication_region +} + +################################################################################ +# Data Sources +################################################################################ + +data "aws_caller_identity" "current" {} +data "aws_region" "current" {} + +################################################################################ +# S3 Bucket +################################################################################ + +resource "aws_s3_bucket" "main" { + bucket = local.bucket_name + + dynamic "object_lock_configuration" { + for_each = local.object_lock_enabled ? [1] : [] + content { + object_lock_enabled = "Enabled" + } + } + + tags = { Name = local.bucket_name } +} + +################################################################################ +# Versioning +################################################################################ + +resource "aws_s3_bucket_versioning" "main" { + bucket = aws_s3_bucket.main.id + + versioning_configuration { + status = local.versioning_enabled ? "Enabled" : "Suspended" + } +} + +################################################################################ +# Encryption +################################################################################ + +resource "aws_s3_bucket_server_side_encryption_configuration" "main" { + bucket = aws_s3_bucket.main.id + + rule { + apply_server_side_encryption_by_default { + sse_algorithm = local.encryption_type == "SSE-S3" ? "AES256" : "aws:kms" + kms_master_key_id = local.encryption_type != "SSE-S3" ? (local.kms_key_arn != null ? local.kms_key_arn : null) : null + } + bucket_key_enabled = local.encryption_type != "SSE-S3" + } +} + +################################################################################ +# Public Access Block +################################################################################ + +resource "aws_s3_bucket_public_access_block" "main" { + bucket = aws_s3_bucket.main.id + + block_public_acls = local.block_public_access + block_public_policy = local.block_public_access + ignore_public_acls = local.block_public_access + restrict_public_buckets = local.block_public_access +} + +################################################################################ +# Access Logging +################################################################################ + +resource "aws_s3_bucket" "logs" { + count = local.enable_logging && local.logging_bucket == null ? 1 : 0 + bucket = "${local.bucket_name}-logs" + + tags = { Name = "${local.bucket_name}-logs" } +} + +resource "aws_s3_bucket_versioning" "logs" { + count = local.enable_logging && local.logging_bucket == null ? 1 : 0 + bucket = aws_s3_bucket.logs[0].id + + versioning_configuration { + status = "Enabled" + } +} + +resource "aws_s3_bucket_server_side_encryption_configuration" "logs" { + count = local.enable_logging && local.logging_bucket == null ? 1 : 0 + bucket = aws_s3_bucket.logs[0].id + + rule { + apply_server_side_encryption_by_default { + sse_algorithm = "AES256" + } + } +} + +resource "aws_s3_bucket_public_access_block" "logs" { + count = local.enable_logging && local.logging_bucket == null ? 1 : 0 + bucket = aws_s3_bucket.logs[0].id + + block_public_acls = true + block_public_policy = true + ignore_public_acls = true + restrict_public_buckets = true +} + +resource "aws_s3_bucket_lifecycle_configuration" "logs" { + count = local.enable_logging && local.logging_bucket == null ? 1 : 0 + bucket = aws_s3_bucket.logs[0].id + + rule { + id = "expire-logs" + status = "Enabled" + + expiration { + days = 90 + } + } +} + +resource "aws_s3_bucket_logging" "main" { + count = local.enable_logging ? 1 : 0 + bucket = aws_s3_bucket.main.id + + target_bucket = local.logging_bucket != null ? local.logging_bucket : aws_s3_bucket.logs[0].id + target_prefix = local.logging_prefix +} + +################################################################################ +# Lifecycle Rules +################################################################################ + +resource "aws_s3_bucket_lifecycle_configuration" "main" { + count = length(local.lifecycle_rules) > 0 ? 1 : 0 + bucket = aws_s3_bucket.main.id + + dynamic "rule" { + for_each = local.lifecycle_rules + content { + id = rule.key + status = rule.value.enabled ? "Enabled" : "Disabled" + + filter { + prefix = lookup(rule.value.filter, "prefix", "") + } + + dynamic "transition" { + for_each = lookup(rule.value, "transitions", []) + content { + days = transition.value.days + storage_class = transition.value.storage_class + } + } + + dynamic "expiration" { + for_each = lookup(rule.value, "expiration_days", null) != null ? [1] : [] + content { + days = rule.value.expiration_days + } + } + + dynamic "noncurrent_version_expiration" { + for_each = lookup(rule.value, "noncurrent_version_expiration_days", null) != null ? [1] : [] + content { + noncurrent_days = rule.value.noncurrent_version_expiration_days + } + } + } + } + + depends_on = [aws_s3_bucket_versioning.main] +} + +################################################################################ +# Intelligent Tiering +################################################################################ + +resource "aws_s3_bucket_intelligent_tiering_configuration" "main" { + count = local.intelligent_tiering_enabled ? 1 : 0 + bucket = aws_s3_bucket.main.id + name = "EntireBucket" + + tiering { + access_tier = "DEEP_ARCHIVE_ACCESS" + days = 180 + } + + tiering { + access_tier = "ARCHIVE_ACCESS" + days = 90 + } +} + +################################################################################ +# CORS +################################################################################ + +resource "aws_s3_bucket_cors_configuration" "main" { + count = local.cors_enabled ? 1 : 0 + bucket = aws_s3_bucket.main.id + + dynamic "cors_rule" { + for_each = local.cors_rules + content { + allowed_headers = cors_rule.value.allowed_headers + allowed_methods = cors_rule.value.allowed_methods + allowed_origins = cors_rule.value.allowed_origins + max_age_seconds = cors_rule.value.max_age_seconds + } + } +} + +################################################################################ +# Object Lock +################################################################################ + +resource "aws_s3_bucket_object_lock_configuration" "main" { + count = local.object_lock_enabled ? 1 : 0 + bucket = aws_s3_bucket.main.id + + rule { + default_retention { + mode = local.object_lock_mode + days = local.object_lock_days + } + } +} + +################################################################################ +# Event Notifications +################################################################################ + +resource "aws_s3_bucket_notification" "main" { + count = length(local.lambda_notifications) > 0 || length(local.sqs_notifications) > 0 ? 1 : 0 + bucket = aws_s3_bucket.main.id + + dynamic "lambda_function" { + for_each = local.lambda_notifications + content { + lambda_function_arn = lambda_function.value.lambda_arn + events = lambda_function.value.events + filter_prefix = lookup(lambda_function.value, "prefix", null) + filter_suffix = lookup(lambda_function.value, "suffix", null) + } + } + + dynamic "queue" { + for_each = local.sqs_notifications + content { + queue_arn = queue.value.queue_arn + events = queue.value.events + filter_prefix = lookup(queue.value, "prefix", null) + filter_suffix = lookup(queue.value, "suffix", null) + } + } +} + +################################################################################ +# Replication +################################################################################ + +resource "aws_s3_bucket" "replica" { + count = local.enable_replication && local.replication_bucket == null ? 1 : 0 + provider = aws.replication + bucket = "${local.bucket_name}-replica" + + tags = { Name = "${local.bucket_name}-replica" } +} + +resource "aws_s3_bucket_versioning" "replica" { + count = local.enable_replication && local.replication_bucket == null ? 1 : 0 + provider = aws.replication + bucket = aws_s3_bucket.replica[0].id + + versioning_configuration { + status = "Enabled" + } +} + +resource "aws_iam_role" "replication" { + count = local.enable_replication ? 1 : 0 + name = "${local.bucket_name}-replication" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Effect = "Allow" + Action = "sts:AssumeRole" + Principal = { Service = "s3.amazonaws.com" } + }] + }) +} + +resource "aws_iam_role_policy" "replication" { + count = local.enable_replication ? 1 : 0 + name = "replication" + role = aws_iam_role.replication[0].id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Effect = "Allow" + Action = [ + "s3:GetReplicationConfiguration", + "s3:ListBucket" + ] + Resource = aws_s3_bucket.main.arn + }, + { + Effect = "Allow" + Action = [ + "s3:GetObjectVersionForReplication", + "s3:GetObjectVersionAcl", + "s3:GetObjectVersionTagging" + ] + Resource = "${aws_s3_bucket.main.arn}/*" + }, + { + Effect = "Allow" + Action = [ + "s3:ReplicateObject", + "s3:ReplicateDelete", + "s3:ReplicateTags" + ] + Resource = "${local.replication_bucket != null ? local.replication_bucket : aws_s3_bucket.replica[0].arn}/*" + } + ] + }) +} + +resource "aws_s3_bucket_replication_configuration" "main" { + count = local.enable_replication ? 1 : 0 + bucket = aws_s3_bucket.main.id + role = aws_iam_role.replication[0].arn + + rule { + id = "replicate-all" + status = "Enabled" + + destination { + bucket = local.replication_bucket != null ? local.replication_bucket : aws_s3_bucket.replica[0].arn + storage_class = "STANDARD" + } + } + + depends_on = [aws_s3_bucket_versioning.main] +} + +################################################################################ +# Outputs +################################################################################ + +output "bucket_name" { + value = aws_s3_bucket.main.id +} + +output "bucket_arn" { + value = aws_s3_bucket.main.arn +} + +output "bucket_domain_name" { + value = aws_s3_bucket.main.bucket_regional_domain_name +} + +output "replica_bucket" { + value = local.enable_replication && local.replication_bucket == null ? aws_s3_bucket.replica[0].id : local.replication_bucket +} + +output "logging_bucket" { + value = local.enable_logging && local.logging_bucket == null ? aws_s3_bucket.logs[0].id : local.logging_bucket +} diff --git a/terraform/05-workloads/_template/secrets-manager/main.tf b/terraform/05-workloads/_template/secrets-manager/main.tf new file mode 100644 index 0000000..f7096dc --- /dev/null +++ b/terraform/05-workloads/_template/secrets-manager/main.tf @@ -0,0 +1,427 @@ +################################################################################ +# Workload: Secrets Manager +# +# Secure secret storage: +# - KMS encryption +# - Automatic rotation (RDS, Redshift, DocumentDB, custom Lambda) +# - Cross-account access policies +# - Versioning and recovery +# - Replication to other regions +# +# Use cases: DB credentials, API keys, certificates, config +################################################################################ + +terraform { + required_version = ">= 1.5" + + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 5.0" + } + random = { + source = "hashicorp/random" + version = ">= 3.0" + } + } + + backend "s3" { + key = "05-workloads/--secrets/terraform.tfstate" + } +} + +################################################################################ +# Configuration - UPDATE THESE +################################################################################ + +locals { + # Naming + tenant = "" + name = "" + env = "prod" + + prefix = "${local.tenant}/${local.env}" + + # KMS encryption (null uses AWS managed key) + kms_key_arn = null + + # Recovery window (days) - 0 for immediate deletion + recovery_window_days = 30 + + # Secrets to create + secrets = { + # Database credentials (auto-generated) + "db/main" = { + description = "Main database credentials" + generate_password = true + password_length = 32 + exclude_characters = "\"@/\\" + secret_string_template = jsonencode({ + username = "admin" + engine = "postgres" + host = "db.example.internal" + port = 5432 + dbname = "main" + }) + # RDS rotation + rotation = { + enabled = false + # lambda_arn = "arn:aws:lambda:..." # Rotation Lambda + # days = 30 + } + } + + # API keys - Update via console or CLI after deployment: + # aws secretsmanager put-secret-value --secret-id --secret-string '{"publishable_key":"pk_live_xxx","secret_key":"sk_live_xxx"}' + "api/stripe" = { + description = "Stripe API keys" + secret_string = jsonencode({ + publishable_key = "pk_live_xxxxxxxxxxxx" + secret_key = "sk_live_xxxxxxxxxxxx" + }) + } + + # Generic config + "config/app" = { + description = "Application configuration" + secret_string = jsonencode({ + feature_flags = { + new_checkout = true + beta_features = false + } + limits = { + max_upload_mb = 100 + rate_limit_rpm = 1000 + } + }) + } + } + + # Cross-account access + allowed_accounts = [ + # "123456789012", # Dev account + # "234567890123", # Staging account + ] + + # IAM principals allowed to read secrets + allowed_principals = [ + # "arn:aws:iam::123456789012:role/app-role", + ] + + # Replication to other regions + replica_regions = [ + # "us-west-2", + # "eu-west-1", + ] +} + +################################################################################ +# Variables +################################################################################ + +variable "region" { + type = string + default = "us-east-1" +} + +variable "state_bucket" { + type = string +} + +################################################################################ +# Provider +################################################################################ + +provider "aws" { + region = var.region + + default_tags { + tags = { + Tenant = local.tenant + App = local.name + Environment = local.env + ManagedBy = "terraform" + } + } +} + +################################################################################ +# Data Sources +################################################################################ + +data "aws_caller_identity" "current" {} +data "aws_region" "current" {} + +################################################################################ +# KMS Key (optional - for customer managed encryption) +################################################################################ + +resource "aws_kms_key" "secrets" { + count = local.kms_key_arn == null ? 1 : 0 + + description = "KMS key for ${local.prefix} secrets" + deletion_window_in_days = 30 + enable_key_rotation = true + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Sid = "Enable IAM policies" + Effect = "Allow" + Principal = { + AWS = "arn:aws:iam::${data.aws_caller_identity.current.account_id}:root" + } + Action = "kms:*" + Resource = "*" + }, + { + Sid = "Allow Secrets Manager" + Effect = "Allow" + Principal = { + Service = "secretsmanager.amazonaws.com" + } + Action = [ + "kms:Encrypt", + "kms:Decrypt", + "kms:ReEncrypt*", + "kms:GenerateDataKey*", + "kms:DescribeKey" + ] + Resource = "*" + } + ] + }) + + tags = { Name = "${local.prefix}-secrets" } +} + +resource "aws_kms_alias" "secrets" { + count = local.kms_key_arn == null ? 1 : 0 + name = "alias/${replace(local.prefix, "/", "-")}-secrets" + target_key_id = aws_kms_key.secrets[0].key_id +} + +locals { + effective_kms_key = local.kms_key_arn != null ? local.kms_key_arn : aws_kms_key.secrets[0].arn +} + +################################################################################ +# Random Passwords +################################################################################ + +resource "random_password" "secrets" { + for_each = { for k, v in local.secrets : k => v if lookup(v, "generate_password", false) } + + length = lookup(each.value, "password_length", 32) + special = true + override_special = lookup(each.value, "override_special", "!#$%&*()-_=+[]{}<>:?") + + # Exclude problematic characters + min_lower = 1 + min_upper = 1 + min_numeric = 1 + min_special = 1 +} + +################################################################################ +# Secrets +################################################################################ + +resource "aws_secretsmanager_secret" "secrets" { + for_each = local.secrets + + name = "${local.prefix}/${each.key}" + description = lookup(each.value, "description", "Secret for ${each.key}") + kms_key_id = local.effective_kms_key + + recovery_window_in_days = local.recovery_window_days + + # Replication + dynamic "replica" { + for_each = local.replica_regions + content { + region = replica.value + kms_key_id = null # Use default key in replica region + } + } + + tags = { Name = "${local.prefix}/${each.key}" } +} + +################################################################################ +# Secret Values +################################################################################ + +resource "aws_secretsmanager_secret_version" "secrets" { + for_each = local.secrets + + secret_id = aws_secretsmanager_secret.secrets[each.key].id + + secret_string = lookup(each.value, "generate_password", false) ? jsonencode(merge( + jsondecode(lookup(each.value, "secret_string_template", "{}")), + { password = random_password.secrets[each.key].result } + )) : lookup(each.value, "secret_string", "{}") +} + +################################################################################ +# Secret Rotation +################################################################################ + +resource "aws_secretsmanager_secret_rotation" "secrets" { + for_each = { for k, v in local.secrets : k => v if lookup(lookup(v, "rotation", {}), "enabled", false) } + + secret_id = aws_secretsmanager_secret.secrets[each.key].id + rotation_lambda_arn = each.value.rotation.lambda_arn + + rotation_rules { + automatically_after_days = lookup(each.value.rotation, "days", 30) + } +} + +################################################################################ +# Resource Policy (Cross-Account Access) +################################################################################ + +resource "aws_secretsmanager_secret_policy" "cross_account" { + for_each = length(local.allowed_accounts) > 0 || length(local.allowed_principals) > 0 ? local.secrets : {} + + secret_arn = aws_secretsmanager_secret.secrets[each.key].arn + + policy = jsonencode({ + Version = "2012-10-17" + Statement = concat( + length(local.allowed_accounts) > 0 ? [{ + Sid = "AllowCrossAccountAccess" + Effect = "Allow" + Principal = { + AWS = [for acct in local.allowed_accounts : "arn:aws:iam::${acct}:root"] + } + Action = [ + "secretsmanager:GetSecretValue", + "secretsmanager:DescribeSecret" + ] + Resource = "*" + }] : [], + length(local.allowed_principals) > 0 ? [{ + Sid = "AllowPrincipalAccess" + Effect = "Allow" + Principal = { + AWS = local.allowed_principals + } + Action = [ + "secretsmanager:GetSecretValue", + "secretsmanager:DescribeSecret" + ] + Resource = "*" + }] : [] + ) + }) +} + +################################################################################ +# IAM Policy for Reading Secrets +################################################################################ + +resource "aws_iam_policy" "read_secrets" { + name = "${replace(local.prefix, "/", "-")}-secrets-read" + description = "Read access to ${local.prefix} secrets" + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Sid = "GetSecrets" + Effect = "Allow" + Action = [ + "secretsmanager:GetSecretValue", + "secretsmanager:DescribeSecret" + ] + Resource = [for s in aws_secretsmanager_secret.secrets : s.arn] + }, + { + Sid = "DecryptSecrets" + Effect = "Allow" + Action = [ + "kms:Decrypt", + "kms:DescribeKey" + ] + Resource = [local.effective_kms_key] + } + ] + }) + + tags = { Name = "${local.prefix}-secrets-read" } +} + +resource "aws_iam_policy" "write_secrets" { + name = "${replace(local.prefix, "/", "-")}-secrets-write" + description = "Write access to ${local.prefix} secrets" + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Sid = "ManageSecrets" + Effect = "Allow" + Action = [ + "secretsmanager:GetSecretValue", + "secretsmanager:DescribeSecret", + "secretsmanager:PutSecretValue", + "secretsmanager:UpdateSecret" + ] + Resource = [for s in aws_secretsmanager_secret.secrets : s.arn] + }, + { + Sid = "EncryptDecryptSecrets" + Effect = "Allow" + Action = [ + "kms:Encrypt", + "kms:Decrypt", + "kms:GenerateDataKey", + "kms:DescribeKey" + ] + Resource = [local.effective_kms_key] + } + ] + }) + + tags = { Name = "${local.prefix}-secrets-write" } +} + +################################################################################ +# Outputs +################################################################################ + +output "secret_arns" { + value = { for k, v in aws_secretsmanager_secret.secrets : k => v.arn } + description = "Secret ARNs" +} + +output "secret_names" { + value = { for k, v in aws_secretsmanager_secret.secrets : k => v.name } + description = "Secret names" +} + +output "kms_key_arn" { + value = local.effective_kms_key + description = "KMS key ARN used for encryption" +} + +output "read_policy_arn" { + value = aws_iam_policy.read_secrets.arn + description = "IAM policy ARN for reading secrets" +} + +output "write_policy_arn" { + value = aws_iam_policy.write_secrets.arn + description = "IAM policy ARN for writing secrets" +} + +output "secret_retrieval_commands" { + value = { + for k, v in aws_secretsmanager_secret.secrets : k => + "aws secretsmanager get-secret-value --secret-id ${v.name} --query SecretString --output text | jq ." + } + description = "CLI commands to retrieve each secret" +} diff --git a/terraform/05-workloads/_template/ses-email/main.tf b/terraform/05-workloads/_template/ses-email/main.tf new file mode 100644 index 0000000..8fbc03e --- /dev/null +++ b/terraform/05-workloads/_template/ses-email/main.tf @@ -0,0 +1,524 @@ +################################################################################ +# Workload: SES Email Configuration +# +# Email sending infrastructure: +# - Domain identity with DKIM +# - Email identities for sending +# - Configuration sets with tracking +# - Event destinations (CloudWatch, SNS, Kinesis) +# - Dedicated IP pools (optional) +# - Suppression list management +# +# Use cases: Transactional email, marketing campaigns, notifications +################################################################################ + +terraform { + required_version = ">= 1.5" + + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 5.0" + } + } + + backend "s3" { + key = "05-workloads/--email/terraform.tfstate" + } +} + +################################################################################ +# Configuration - UPDATE THESE +################################################################################ + +locals { + # Naming + tenant = "" + name = "" + env = "prod" + + config_name = "${local.tenant}-${local.name}-${local.env}" + + # Domain to verify (required) + domain = "example.com" + hosted_zone_id = null # Route53 zone ID for automatic DNS verification + + # Additional email identities + email_identities = [ + # "noreply@example.com", + # "support@example.com", + ] + + # MAIL FROM domain (optional custom subdomain) + mail_from_subdomain = "mail" # Results in mail.example.com + + # DMARC record + enable_dmarc = true + dmarc_policy = "none" # none, quarantine, reject + dmarc_rua = null # Aggregate report email, e.g., "mailto:dmarc@example.com" + + # Configuration set (for tracking) + enable_config_set = true + + # Event tracking + tracking_options = { + click = true + open = true + bounce = true + complaint = true + delivery = true + reject = true + send = true + } + + # Event destinations + cloudwatch_destination = true + sns_destination = true + + # Reputation metrics + reputation_metrics_enabled = true + + # Sending quotas (request increase via AWS support) + # These are informational - actual limits set by AWS + + # Suppression list + suppression_list_reasons = ["BOUNCE", "COMPLAINT"] + + # Dedicated IPs (additional cost) + enable_dedicated_ips = false + dedicated_ip_count = 0 + + # IAM policy for sending + create_sending_role = true + sending_role_name = "${local.config_name}-ses-sender" +} + +################################################################################ +# Variables +################################################################################ + +variable "region" { + type = string + default = "us-east-1" +} + +variable "state_bucket" { + type = string +} + +################################################################################ +# Provider +################################################################################ + +provider "aws" { + region = var.region + + default_tags { + tags = { + Tenant = local.tenant + App = local.name + Environment = local.env + ManagedBy = "terraform" + } + } +} + +################################################################################ +# Data Sources +################################################################################ + +data "aws_caller_identity" "current" {} +data "aws_region" "current" {} + +################################################################################ +# Domain Identity +################################################################################ + +resource "aws_ses_domain_identity" "main" { + domain = local.domain +} + +resource "aws_ses_domain_dkim" "main" { + domain = aws_ses_domain_identity.main.domain +} + +################################################################################ +# DNS Records (if hosted zone provided) +################################################################################ + +# Domain verification +resource "aws_route53_record" "ses_verification" { + count = local.hosted_zone_id != null ? 1 : 0 + zone_id = local.hosted_zone_id + name = "_amazonses.${local.domain}" + type = "TXT" + ttl = 600 + records = [aws_ses_domain_identity.main.verification_token] +} + +# DKIM records +resource "aws_route53_record" "dkim" { + count = local.hosted_zone_id != null ? 3 : 0 + zone_id = local.hosted_zone_id + name = "${aws_ses_domain_dkim.main.dkim_tokens[count.index]}._domainkey.${local.domain}" + type = "CNAME" + ttl = 600 + records = ["${aws_ses_domain_dkim.main.dkim_tokens[count.index]}.dkim.amazonses.com"] +} + +# MAIL FROM domain +resource "aws_ses_domain_mail_from" "main" { + domain = aws_ses_domain_identity.main.domain + mail_from_domain = "${local.mail_from_subdomain}.${local.domain}" +} + +resource "aws_route53_record" "mail_from_mx" { + count = local.hosted_zone_id != null ? 1 : 0 + zone_id = local.hosted_zone_id + name = "${local.mail_from_subdomain}.${local.domain}" + type = "MX" + ttl = 600 + records = ["10 feedback-smtp.${data.aws_region.current.name}.amazonses.com"] +} + +resource "aws_route53_record" "mail_from_spf" { + count = local.hosted_zone_id != null ? 1 : 0 + zone_id = local.hosted_zone_id + name = "${local.mail_from_subdomain}.${local.domain}" + type = "TXT" + ttl = 600 + records = ["v=spf1 include:amazonses.com ~all"] +} + +# DMARC record +resource "aws_route53_record" "dmarc" { + count = local.hosted_zone_id != null && local.enable_dmarc ? 1 : 0 + zone_id = local.hosted_zone_id + name = "_dmarc.${local.domain}" + type = "TXT" + ttl = 600 + records = [ + local.dmarc_rua != null + ? "v=DMARC1; p=${local.dmarc_policy}; rua=${local.dmarc_rua}" + : "v=DMARC1; p=${local.dmarc_policy}" + ] +} + +################################################################################ +# Email Identities +################################################################################ + +resource "aws_ses_email_identity" "identities" { + for_each = toset(local.email_identities) + email = each.value +} + +################################################################################ +# Configuration Set +################################################################################ + +resource "aws_ses_configuration_set" "main" { + count = local.enable_config_set ? 1 : 0 + name = local.config_name + + reputation_metrics_enabled = local.reputation_metrics_enabled + + delivery_options { + tls_policy = "REQUIRE" + } + + tracking_options { + custom_redirect_domain = null + } +} + +################################################################################ +# SNS Topic for Events +################################################################################ + +resource "aws_sns_topic" "ses_events" { + count = local.sns_destination ? 1 : 0 + name = "${local.config_name}-ses-events" + kms_master_key_id = "alias/aws/sns" + + tags = { Name = "${local.config_name}-ses-events" } +} + +resource "aws_sns_topic_policy" "ses_events" { + count = local.sns_destination ? 1 : 0 + arn = aws_sns_topic.ses_events[0].arn + policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Sid = "AllowSES" + Effect = "Allow" + Principal = { + Service = "ses.amazonaws.com" + } + Action = "sns:Publish" + Resource = aws_sns_topic.ses_events[0].arn + Condition = { + StringEquals = { + "AWS:SourceAccount" = data.aws_caller_identity.current.account_id + } + } + }] + }) +} + +################################################################################ +# Event Destinations +################################################################################ + +resource "aws_ses_event_destination" "cloudwatch" { + count = local.enable_config_set && local.cloudwatch_destination ? 1 : 0 + name = "cloudwatch" + configuration_set_name = aws_ses_configuration_set.main[0].name + enabled = true + + matching_types = compact([ + local.tracking_options.bounce ? "bounce" : "", + local.tracking_options.complaint ? "complaint" : "", + local.tracking_options.delivery ? "delivery" : "", + local.tracking_options.send ? "send" : "", + local.tracking_options.reject ? "reject" : "", + local.tracking_options.open ? "open" : "", + local.tracking_options.click ? "click" : "", + ]) + + cloudwatch_destination { + default_value = "default" + dimension_name = "ses:source-ip" + value_source = "messageTag" + } +} + +resource "aws_ses_event_destination" "sns" { + count = local.enable_config_set && local.sns_destination ? 1 : 0 + name = "sns" + configuration_set_name = aws_ses_configuration_set.main[0].name + enabled = true + + matching_types = ["bounce", "complaint"] + + sns_destination { + topic_arn = aws_sns_topic.ses_events[0].arn + } +} + +################################################################################ +# IAM Role for Sending +################################################################################ + +resource "aws_iam_role" "sending" { + count = local.create_sending_role ? 1 : 0 + name = local.sending_role_name + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Effect = "Allow" + Principal = { + Service = [ + "lambda.amazonaws.com", + "ecs-tasks.amazonaws.com", + "ec2.amazonaws.com" + ] + } + Action = "sts:AssumeRole" + }] + }) + + tags = { Name = local.sending_role_name } +} + +resource "aws_iam_role_policy" "sending" { + count = local.create_sending_role ? 1 : 0 + name = "ses-sending" + role = aws_iam_role.sending[0].id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Sid = "SendEmail" + Effect = "Allow" + Action = [ + "ses:SendEmail", + "ses:SendRawEmail", + "ses:SendTemplatedEmail", + "ses:SendBulkTemplatedEmail" + ] + Resource = "*" + Condition = { + StringEquals = { + "ses:FromAddress" = [for e in local.email_identities : e] + } + } + }, + { + Sid = "UseConfigSet" + Effect = "Allow" + Action = ["ses:SendEmail", "ses:SendRawEmail"] + Resource = local.enable_config_set ? aws_ses_configuration_set.main[0].arn : "*" + } + ] + }) +} + +################################################################################ +# SMTP Credentials (for apps that use SMTP) +################################################################################ + +resource "aws_iam_user" "smtp" { + name = "${local.config_name}-smtp" + tags = { Name = "${local.config_name}-smtp" } +} + +resource "aws_iam_user_policy" "smtp" { + name = "ses-smtp" + user = aws_iam_user.smtp.name + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Effect = "Allow" + Action = "ses:SendRawEmail" + Resource = "*" + }] + }) +} + +resource "aws_iam_access_key" "smtp" { + user = aws_iam_user.smtp.name +} + +################################################################################ +# Email Templates (Examples) +################################################################################ + +resource "aws_ses_template" "welcome" { + name = "${local.config_name}-welcome" + subject = "Welcome to {{company_name}}!" + html = <<-HTML + + + + +

Welcome, {{name}}!

+

Thank you for signing up for {{company_name}}.

+

Click here to verify your email.

+ + + HTML + text = <<-TEXT + Welcome, {{name}}! + + Thank you for signing up for {{company_name}}. + + Click the link below to verify your email: + {{verification_link}} + TEXT +} + +resource "aws_ses_template" "password_reset" { + name = "${local.config_name}-password-reset" + subject = "Reset your {{company_name}} password" + html = <<-HTML + + + + +

Password Reset Request

+

Hi {{name}},

+

Click here to reset your password.

+

This link expires in {{expiry_hours}} hours.

+

If you didn't request this, please ignore this email.

+ + + HTML + text = <<-TEXT + Password Reset Request + + Hi {{name}}, + + Click the link below to reset your password: + {{reset_link}} + + This link expires in {{expiry_hours}} hours. + + If you didn't request this, please ignore this email. + TEXT +} + +################################################################################ +# Outputs +################################################################################ + +output "domain_identity_arn" { + value = aws_ses_domain_identity.main.arn +} + +output "domain_verification_token" { + value = aws_ses_domain_identity.main.verification_token +} + +output "dkim_tokens" { + value = aws_ses_domain_dkim.main.dkim_tokens +} + +output "configuration_set" { + value = local.enable_config_set ? aws_ses_configuration_set.main[0].name : null +} + +output "sns_topic_arn" { + value = local.sns_destination ? aws_sns_topic.ses_events[0].arn : null +} + +output "sending_role_arn" { + value = local.create_sending_role ? aws_iam_role.sending[0].arn : null +} + +output "smtp_credentials" { + value = { + username = aws_iam_access_key.smtp.id + password = aws_iam_access_key.smtp.ses_smtp_password_v4 + endpoint = "email-smtp.${data.aws_region.current.name}.amazonaws.com" + port = 587 + } + sensitive = true +} + +output "dns_records_required" { + value = local.hosted_zone_id == null ? { + verification = { + name = "_amazonses.${local.domain}" + type = "TXT" + value = aws_ses_domain_identity.main.verification_token + } + dkim = [ + for i, token in aws_ses_domain_dkim.main.dkim_tokens : { + name = "${token}._domainkey.${local.domain}" + type = "CNAME" + value = "${token}.dkim.amazonses.com" + } + ] + mail_from_mx = { + name = "${local.mail_from_subdomain}.${local.domain}" + type = "MX" + value = "10 feedback-smtp.${data.aws_region.current.name}.amazonses.com" + } + mail_from_spf = { + name = "${local.mail_from_subdomain}.${local.domain}" + type = "TXT" + value = "v=spf1 include:amazonses.com ~all" + } + } : "DNS records created automatically" +} + +output "templates" { + value = { + welcome = aws_ses_template.welcome.name + password_reset = aws_ses_template.password_reset.name + } +} diff --git a/terraform/05-workloads/_template/sns-topic/main.tf b/terraform/05-workloads/_template/sns-topic/main.tf new file mode 100644 index 0000000..8214f48 --- /dev/null +++ b/terraform/05-workloads/_template/sns-topic/main.tf @@ -0,0 +1,383 @@ +################################################################################ +# Workload: SNS Topic +# +# Pub/Sub messaging with: +# - Multiple subscription types (Lambda, SQS, HTTP, Email, SMS) +# - Message filtering +# - Dead letter queue +# - KMS encryption +# - Cross-account publishing +# - FIFO topics (ordered, exactly-once) +# +# Use cases: Event fan-out, notifications, decoupling services +################################################################################ + +terraform { + required_version = ">= 1.5" + + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 5.0" + } + } + + backend "s3" { + key = "05-workloads/--sns/terraform.tfstate" + } +} + +################################################################################ +# Configuration - UPDATE THESE +################################################################################ + +locals { + # Naming + tenant = "" + name = "" + env = "prod" + + topic_name = "${local.tenant}-${local.name}-${local.env}" + + # FIFO topic (ordered, exactly-once delivery) + fifo_topic = false + content_based_deduplication = false + + # Encryption + kms_key_arn = null # null = AWS managed key + + # Message delivery settings + delivery_policy = { + http = { + defaultHealthyRetryPolicy = { + minDelayTarget = 20 + maxDelayTarget = 20 + numRetries = 3 + numMaxDelayRetries = 0 + numNoDelayRetries = 0 + numMinDelayRetries = 0 + backoffFunction = "linear" + } + disableSubscriptionOverrides = false + } + } + + # Subscriptions + subscriptions = { + # Lambda subscription + # "process-events" = { + # protocol = "lambda" + # endpoint = "arn:aws:lambda:us-east-1:123456789012:function:process-events" + # filter_policy = { + # event_type = ["order.created", "order.updated"] + # } + # } + + # SQS subscription + # "event-queue" = { + # protocol = "sqs" + # endpoint = "arn:aws:sqs:us-east-1:123456789012:event-queue" + # raw_message_delivery = true + # } + + # Email subscription + # "alerts" = { + # protocol = "email" + # endpoint = "alerts@example.com" + # } + + # HTTP/HTTPS subscription + # "webhook" = { + # protocol = "https" + # endpoint = "https://api.example.com/webhook" + # filter_policy = { + # severity = ["high", "critical"] + # } + # } + } + + # Cross-account publish access + publish_accounts = [ + # "123456789012", + ] + + # Cross-account subscribe access + subscribe_accounts = [ + # "234567890123", + ] + + # AWS service publish access + aws_service_principals = [ + # "events.amazonaws.com", # EventBridge + # "cloudwatch.amazonaws.com", # CloudWatch Alarms + # "s3.amazonaws.com", # S3 Event Notifications + # "ses.amazonaws.com", # SES Notifications + ] + + # Dead letter queue for failed deliveries + enable_dlq = true +} + +################################################################################ +# Variables +################################################################################ + +variable "region" { + type = string + default = "us-east-1" +} + +variable "state_bucket" { + type = string +} + +################################################################################ +# Provider +################################################################################ + +provider "aws" { + region = var.region + + default_tags { + tags = { + Tenant = local.tenant + App = local.name + Environment = local.env + ManagedBy = "terraform" + } + } +} + +################################################################################ +# Data Sources +################################################################################ + +data "aws_caller_identity" "current" {} +data "aws_region" "current" {} + +################################################################################ +# SNS Topic +################################################################################ + +resource "aws_sns_topic" "main" { + name = local.fifo_topic ? "${local.topic_name}.fifo" : local.topic_name + + fifo_topic = local.fifo_topic + content_based_deduplication = local.fifo_topic ? local.content_based_deduplication : null + + kms_master_key_id = local.kms_key_arn != null ? local.kms_key_arn : "alias/aws/sns" + + delivery_policy = jsonencode(local.delivery_policy) + + tags = { Name = local.topic_name } +} + +################################################################################ +# Dead Letter Queue +################################################################################ + +resource "aws_sqs_queue" "dlq" { + count = local.enable_dlq ? 1 : 0 + name = local.fifo_topic ? "${local.topic_name}-dlq.fifo" : "${local.topic_name}-dlq" + + fifo_queue = local.fifo_topic + content_based_deduplication = local.fifo_topic + + message_retention_seconds = 1209600 # 14 days + kms_master_key_id = "alias/aws/sqs" + + tags = { Name = "${local.topic_name}-dlq" } +} + +resource "aws_sqs_queue_policy" "dlq" { + count = local.enable_dlq ? 1 : 0 + queue_url = aws_sqs_queue.dlq[0].id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Sid = "AllowSNS" + Effect = "Allow" + Principal = { + Service = "sns.amazonaws.com" + } + Action = "sqs:SendMessage" + Resource = aws_sqs_queue.dlq[0].arn + Condition = { + ArnEquals = { + "aws:SourceArn" = aws_sns_topic.main.arn + } + } + }] + }) +} + +################################################################################ +# Topic Policy +################################################################################ + +resource "aws_sns_topic_policy" "main" { + arn = aws_sns_topic.main.arn + + policy = jsonencode({ + Version = "2012-10-17" + Statement = concat( + # Allow account root + [{ + Sid = "DefaultPolicy" + Effect = "Allow" + Principal = { + AWS = "arn:aws:iam::${data.aws_caller_identity.current.account_id}:root" + } + Action = [ + "sns:Publish", + "sns:Subscribe", + "sns:Receive", + "sns:ListSubscriptionsByTopic", + "sns:GetTopicAttributes" + ] + Resource = aws_sns_topic.main.arn + }], + + # Cross-account publish + length(local.publish_accounts) > 0 ? [{ + Sid = "CrossAccountPublish" + Effect = "Allow" + Principal = { + AWS = [for acct in local.publish_accounts : "arn:aws:iam::${acct}:root"] + } + Action = "sns:Publish" + Resource = aws_sns_topic.main.arn + }] : [], + + # Cross-account subscribe + length(local.subscribe_accounts) > 0 ? [{ + Sid = "CrossAccountSubscribe" + Effect = "Allow" + Principal = { + AWS = [for acct in local.subscribe_accounts : "arn:aws:iam::${acct}:root"] + } + Action = "sns:Subscribe" + Resource = aws_sns_topic.main.arn + }] : [], + + # AWS service access + length(local.aws_service_principals) > 0 ? [{ + Sid = "AWSServicePublish" + Effect = "Allow" + Principal = { + Service = local.aws_service_principals + } + Action = "sns:Publish" + Resource = aws_sns_topic.main.arn + Condition = { + StringEquals = { + "aws:SourceAccount" = data.aws_caller_identity.current.account_id + } + } + }] : [] + ) + }) +} + +################################################################################ +# Subscriptions +################################################################################ + +resource "aws_sns_topic_subscription" "subscriptions" { + for_each = local.subscriptions + + topic_arn = aws_sns_topic.main.arn + protocol = each.value.protocol + endpoint = each.value.endpoint + + filter_policy = lookup(each.value, "filter_policy", null) != null ? jsonencode(each.value.filter_policy) : null + filter_policy_scope = lookup(each.value, "filter_policy", null) != null ? "MessageAttributes" : null + raw_message_delivery = lookup(each.value, "raw_message_delivery", false) + + redrive_policy = local.enable_dlq ? jsonencode({ + deadLetterTargetArn = aws_sqs_queue.dlq[0].arn + }) : null +} + +# Lambda permissions for SNS to invoke +resource "aws_lambda_permission" "sns" { + for_each = { for k, v in local.subscriptions : k => v if v.protocol == "lambda" } + + statement_id = "AllowSNS-${each.key}" + action = "lambda:InvokeFunction" + function_name = regex("function:([^:]+)$", each.value.endpoint)[0] + principal = "sns.amazonaws.com" + source_arn = aws_sns_topic.main.arn +} + +################################################################################ +# IAM Policies +################################################################################ + +resource "aws_iam_policy" "publish" { + name = "${local.topic_name}-sns-publish" + description = "Publish to ${local.topic_name} SNS topic" + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Sid = "PublishToTopic" + Effect = "Allow" + Action = "sns:Publish" + Resource = aws_sns_topic.main.arn + }, + { + Sid = "DecryptKMS" + Effect = "Allow" + Action = [ + "kms:Decrypt", + "kms:GenerateDataKey" + ] + Resource = local.kms_key_arn != null ? [local.kms_key_arn] : ["arn:aws:kms:${data.aws_region.current.name}:${data.aws_caller_identity.current.account_id}:alias/aws/sns"] + } + ] + }) + + tags = { Name = "${local.topic_name}-publish" } +} + +################################################################################ +# Outputs +################################################################################ + +output "topic_arn" { + value = aws_sns_topic.main.arn + description = "SNS topic ARN" +} + +output "topic_name" { + value = aws_sns_topic.main.name + description = "SNS topic name" +} + +output "dlq_arn" { + value = local.enable_dlq ? aws_sqs_queue.dlq[0].arn : null + description = "Dead letter queue ARN" +} + +output "dlq_url" { + value = local.enable_dlq ? aws_sqs_queue.dlq[0].url : null + description = "Dead letter queue URL" +} + +output "publish_policy_arn" { + value = aws_iam_policy.publish.arn + description = "IAM policy ARN for publishing" +} + +output "subscription_arns" { + value = { for k, v in aws_sns_topic_subscription.subscriptions : k => v.arn } + description = "Subscription ARNs" +} + +output "publish_example" { + value = "aws sns publish --topic-arn ${aws_sns_topic.main.arn} --message '{\"event\": \"test\"}' --message-attributes '{\"event_type\": {\"DataType\": \"String\", \"StringValue\": \"test\"}}'" + description = "Example publish command" +} diff --git a/terraform/05-workloads/_template/sqs-queue/main.tf b/terraform/05-workloads/_template/sqs-queue/main.tf new file mode 100644 index 0000000..1c2ca9d --- /dev/null +++ b/terraform/05-workloads/_template/sqs-queue/main.tf @@ -0,0 +1,379 @@ +################################################################################ +# Workload: SQS Queue +# +# Deploys a managed message queue: +# - Main queue with DLQ (dead letter queue) +# - Server-side encryption +# - CloudWatch alarms +# - Optional FIFO support +# +# Usage: +# Copy this folder to 05-workloads/--queue/ +# Update locals and variables +# terraform init -backend-config=../../00-bootstrap/backend.hcl +# terraform apply +################################################################################ + +terraform { + required_version = ">= 1.5" + + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 5.0" + } + } + + backend "s3" { + key = "05-workloads/--queue/terraform.tfstate" + } +} + +################################################################################ +# Configuration - UPDATE THESE +################################################################################ + +locals { + # Naming + tenant = "" + name = "" + env = "prod" # prod, staging, dev + + # Queue name (will add .fifo suffix if FIFO enabled) + queue_name = "${local.tenant}-${local.name}-${local.env}" + + # FIFO queue (exactly-once processing, ordered) + fifo_queue = false + content_based_deduplication = false # Only for FIFO + + # Message settings + message_retention_seconds = 1209600 # 14 days (max) + max_message_size = 262144 # 256 KB (max) + delay_seconds = 0 # Delay before message becomes visible + receive_wait_time_seconds = 20 # Long polling (cost efficient) + + # Visibility timeout (should be > consumer processing time) + visibility_timeout_seconds = 300 # 5 minutes + + # Dead letter queue settings + max_receive_count = 3 # Messages go to DLQ after this many failed receives + dlq_retention_days = 14 + + # Alarm thresholds + alarm_age_threshold = 300 # 5 minutes - message age alarm + alarm_messages_threshold = 1000 # Queue depth alarm +} + +################################################################################ +# Variables +################################################################################ + +variable "region" { + type = string + default = "us-east-1" +} + +variable "state_bucket" { + type = string +} + +################################################################################ +# Provider +################################################################################ + +provider "aws" { + region = var.region + + default_tags { + tags = { + Tenant = local.tenant + App = local.name + Environment = local.env + ManagedBy = "terraform" + } + } +} + +################################################################################ +# Data Sources +################################################################################ + +data "terraform_remote_state" "tenant" { + backend = "s3" + config = { + bucket = var.state_bucket + key = "04-tenants/${local.tenant}/terraform.tfstate" + region = var.region + } +} + +data "aws_caller_identity" "current" {} +data "aws_region" "current" {} + +################################################################################ +# KMS Key +################################################################################ + +resource "aws_kms_key" "sqs" { + description = "KMS key for ${local.queue_name} SQS encryption" + deletion_window_in_days = 7 + enable_key_rotation = true + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Sid = "Enable IAM User Permissions" + Effect = "Allow" + Principal = { + AWS = "arn:aws:iam::${data.aws_caller_identity.current.account_id}:root" + } + Action = "kms:*" + Resource = "*" + }, + { + Sid = "Allow SQS Service" + Effect = "Allow" + Principal = { + Service = "sqs.amazonaws.com" + } + Action = [ + "kms:Decrypt", + "kms:GenerateDataKey" + ] + Resource = "*" + }, + { + Sid = "Allow SNS to use this key" + Effect = "Allow" + Principal = { + Service = "sns.amazonaws.com" + } + Action = [ + "kms:Decrypt", + "kms:GenerateDataKey" + ] + Resource = "*" + } + ] + }) + + tags = { Name = "${local.queue_name}-sqs" } +} + +resource "aws_kms_alias" "sqs" { + name = "alias/${local.queue_name}-sqs" + target_key_id = aws_kms_key.sqs.key_id +} + +################################################################################ +# Dead Letter Queue +################################################################################ + +resource "aws_sqs_queue" "dlq" { + name = local.fifo_queue ? "${local.queue_name}-dlq.fifo" : "${local.queue_name}-dlq" + + fifo_queue = local.fifo_queue + + message_retention_seconds = local.dlq_retention_days * 86400 + kms_master_key_id = aws_kms_key.sqs.id + kms_data_key_reuse_period_seconds = 86400 # 24 hours + + tags = { Name = "${local.queue_name}-dlq" } +} + +################################################################################ +# Main Queue +################################################################################ + +resource "aws_sqs_queue" "main" { + name = local.fifo_queue ? "${local.queue_name}.fifo" : local.queue_name + + fifo_queue = local.fifo_queue + content_based_deduplication = local.fifo_queue ? local.content_based_deduplication : null + + message_retention_seconds = local.message_retention_seconds + max_message_size = local.max_message_size + delay_seconds = local.delay_seconds + receive_message_wait_time_seconds = local.receive_wait_time_seconds + visibility_timeout_seconds = local.visibility_timeout_seconds + + # Encryption + kms_master_key_id = aws_kms_key.sqs.id + kms_data_key_reuse_period_seconds = 86400 # 24 hours + + # Dead letter queue + redrive_policy = jsonencode({ + deadLetterTargetArn = aws_sqs_queue.dlq.arn + maxReceiveCount = local.max_receive_count + }) + + tags = { Name = local.queue_name } +} + +# Allow DLQ redrive +resource "aws_sqs_queue_redrive_allow_policy" "dlq" { + queue_url = aws_sqs_queue.dlq.id + + redrive_allow_policy = jsonencode({ + redrivePermission = "byQueue" + sourceQueueArns = [aws_sqs_queue.main.arn] + }) +} + +################################################################################ +# Queue Policy +################################################################################ + +resource "aws_sqs_queue_policy" "main" { + queue_url = aws_sqs_queue.main.id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Sid = "AllowTenantAccess" + Effect = "Allow" + Principal = { + AWS = "arn:aws:iam::${data.aws_caller_identity.current.account_id}:root" + } + Action = [ + "sqs:SendMessage", + "sqs:ReceiveMessage", + "sqs:DeleteMessage", + "sqs:GetQueueAttributes", + "sqs:GetQueueUrl" + ] + Resource = aws_sqs_queue.main.arn + Condition = { + StringEquals = { + "aws:PrincipalTag/Tenant" = local.tenant + } + } + }, + { + Sid = "DenyInsecureTransport" + Effect = "Deny" + Principal = "*" + Action = "sqs:*" + Resource = aws_sqs_queue.main.arn + Condition = { + Bool = { + "aws:SecureTransport" = "false" + } + } + } + ] + }) +} + +################################################################################ +# SNS Topic for Alarms +################################################################################ + +resource "aws_sns_topic" "alarms" { + name = "${local.queue_name}-alarms" + + tags = { Name = "${local.queue_name}-alarms" } +} + +################################################################################ +# CloudWatch Alarms +################################################################################ + +# Queue depth alarm +resource "aws_cloudwatch_metric_alarm" "depth" { + alarm_name = "${local.queue_name}-depth" + comparison_operator = "GreaterThanThreshold" + evaluation_periods = 2 + metric_name = "ApproximateNumberOfMessagesVisible" + namespace = "AWS/SQS" + period = 300 + statistic = "Average" + threshold = local.alarm_messages_threshold + alarm_description = "Queue depth high - messages may be backing up" + + dimensions = { + QueueName = aws_sqs_queue.main.name + } + + alarm_actions = [aws_sns_topic.alarms.arn] + ok_actions = [aws_sns_topic.alarms.arn] + + tags = { Name = "${local.queue_name}-depth" } +} + +# Message age alarm +resource "aws_cloudwatch_metric_alarm" "age" { + alarm_name = "${local.queue_name}-age" + comparison_operator = "GreaterThanThreshold" + evaluation_periods = 2 + metric_name = "ApproximateAgeOfOldestMessage" + namespace = "AWS/SQS" + period = 300 + statistic = "Maximum" + threshold = local.alarm_age_threshold + alarm_description = "Oldest message age high - consumers may be failing" + + dimensions = { + QueueName = aws_sqs_queue.main.name + } + + alarm_actions = [aws_sns_topic.alarms.arn] + ok_actions = [aws_sns_topic.alarms.arn] + + tags = { Name = "${local.queue_name}-age" } +} + +# DLQ messages alarm (critical - messages are failing) +resource "aws_cloudwatch_metric_alarm" "dlq" { + alarm_name = "${local.queue_name}-dlq" + comparison_operator = "GreaterThanThreshold" + evaluation_periods = 1 + metric_name = "ApproximateNumberOfMessagesVisible" + namespace = "AWS/SQS" + period = 300 + statistic = "Sum" + threshold = 0 + alarm_description = "Messages in DLQ - processing failures detected" + + dimensions = { + QueueName = aws_sqs_queue.dlq.name + } + + alarm_actions = [aws_sns_topic.alarms.arn] + + tags = { Name = "${local.queue_name}-dlq" } +} + +################################################################################ +# Outputs +################################################################################ + +output "queue_url" { + value = aws_sqs_queue.main.url +} + +output "queue_arn" { + value = aws_sqs_queue.main.arn +} + +output "queue_name" { + value = aws_sqs_queue.main.name +} + +output "dlq_url" { + value = aws_sqs_queue.dlq.url +} + +output "dlq_arn" { + value = aws_sqs_queue.dlq.arn +} + +output "kms_key_arn" { + value = aws_kms_key.sqs.arn +} + +output "alarm_topic_arn" { + value = aws_sns_topic.alarms.arn +} diff --git a/terraform/05-workloads/_template/ssm-parameters/main.tf b/terraform/05-workloads/_template/ssm-parameters/main.tf new file mode 100644 index 0000000..01d37d3 --- /dev/null +++ b/terraform/05-workloads/_template/ssm-parameters/main.tf @@ -0,0 +1,343 @@ +################################################################################ +# Workload: SSM Parameter Store +# +# Configuration management (cheaper than Secrets Manager for non-secrets): +# - String, StringList, SecureString parameters +# - Hierarchical paths for organization +# - KMS encryption for SecureString +# - Parameter policies (expiration, notification) +# - Cross-account access +# +# Cost: Free for standard parameters, $0.05/10K API calls for advanced +# Use Secrets Manager for: rotation, cross-region replication, RDS integration +################################################################################ + +terraform { + required_version = ">= 1.5" + + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 5.0" + } + } + + backend "s3" { + key = "05-workloads/--params/terraform.tfstate" + } +} + +################################################################################ +# Configuration - UPDATE THESE +################################################################################ + +locals { + # Naming + tenant = "" + name = "" + env = "prod" + + prefix = "/${local.tenant}/${local.env}/${local.name}" + + # KMS key for SecureString (null = AWS managed key) + kms_key_arn = null + + # Parameter tier: Standard (free, 4KB) or Advanced ($0.05/param/mo, 8KB) + tier = "Standard" + + # Parameters to create + parameters = { + # Application config + "config/app_name" = { + type = "String" + value = local.name + description = "Application name" + } + + "config/environment" = { + type = "String" + value = local.env + description = "Environment name" + } + + "config/log_level" = { + type = "String" + value = "INFO" + description = "Application log level" + } + + "config/feature_flags" = { + type = "String" + value = jsonencode({ + new_checkout = true + dark_mode = false + beta_features = false + }) + description = "Feature flags JSON" + } + + # Database config (non-secret parts) + "database/host" = { + type = "String" + value = "db.example.internal" + description = "Database hostname" + } + + "database/port" = { + type = "String" + value = "5432" + description = "Database port" + } + + "database/name" = { + type = "String" + value = "myapp" + description = "Database name" + } + + # Secure values (encrypted with KMS) + # Note: Update this value after deployment via CLI: + # aws ssm put-parameter --name "////secrets/api_key" --value "real-secret" --type SecureString --overwrite + "secrets/api_key" = { + type = "SecureString" + value = "initial-value-update-after-deploy" + description = "External API key" + } + + # List example + "config/allowed_origins" = { + type = "StringList" + value = "https://example.com,https://app.example.com" + description = "CORS allowed origins" + } + } + + # Parameters with expiration policies (Advanced tier only) + expiring_parameters = { + # "tokens/temp_token" = { + # type = "SecureString" + # value = "temp-value" + # description = "Temporary token" + # expiration = "2024-12-31T23:59:59Z" + # } + } +} + +################################################################################ +# Variables +################################################################################ + +variable "region" { + type = string + default = "us-east-1" +} + +variable "state_bucket" { + type = string +} + +################################################################################ +# Provider +################################################################################ + +provider "aws" { + region = var.region + + default_tags { + tags = { + Tenant = local.tenant + App = local.name + Environment = local.env + ManagedBy = "terraform" + } + } +} + +################################################################################ +# Data Sources +################################################################################ + +data "aws_caller_identity" "current" {} +data "aws_region" "current" {} + +################################################################################ +# SSM Parameters +################################################################################ + +resource "aws_ssm_parameter" "params" { + for_each = local.parameters + + name = "${local.prefix}/${each.key}" + description = lookup(each.value, "description", "Parameter ${each.key}") + type = each.value.type + value = each.value.value + tier = local.tier + + key_id = each.value.type == "SecureString" ? local.kms_key_arn : null + + tags = { + Name = "${local.prefix}/${each.key}" + Type = each.value.type + } + + # Uncomment to prevent Terraform from updating SecureString values + # (useful when managing secrets externally via CLI/console) + # lifecycle { + # ignore_changes = [value] + # } +} + +################################################################################ +# Parameters with Expiration (Advanced Tier) +################################################################################ + +resource "aws_ssm_parameter" "expiring" { + for_each = local.expiring_parameters + + name = "${local.prefix}/${each.key}" + description = lookup(each.value, "description", "Parameter ${each.key}") + type = each.value.type + value = each.value.value + tier = "Advanced" # Required for policies + overwrite = true # Allow updates to existing parameters + + key_id = each.value.type == "SecureString" ? local.kms_key_arn : null + + # Note: Parameter policies (expiration, notification) require AWS SDK/CLI + # Use aws ssm put-parameter with --policies flag for expiration: + # aws ssm put-parameter --name "/path/param" --policies '[{"Type":"Expiration","Version":"1.0","Attributes":{"Timestamp":"2024-12-31T23:59:59.000Z"}}]' + + tags = { + Name = "${local.prefix}/${each.key}" + Type = each.value.type + Expiration = lookup(each.value, "expiration", "none") + } +} + +################################################################################ +# IAM Policy for Reading Parameters +################################################################################ + +resource "aws_iam_policy" "read" { + name = "${local.tenant}-${local.name}-ssm-read" + description = "Read access to ${local.prefix} parameters" + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Sid = "DescribeParameters" + Effect = "Allow" + Action = [ + "ssm:DescribeParameters" + ] + Resource = "*" + }, + { + Sid = "GetParameters" + Effect = "Allow" + Action = [ + "ssm:GetParameter", + "ssm:GetParameters", + "ssm:GetParametersByPath" + ] + Resource = "arn:aws:ssm:${data.aws_region.current.name}:${data.aws_caller_identity.current.account_id}:parameter${local.prefix}/*" + }, + { + Sid = "DecryptSecureStrings" + Effect = "Allow" + Action = [ + "kms:Decrypt" + ] + Resource = local.kms_key_arn != null ? [local.kms_key_arn] : ["arn:aws:kms:${data.aws_region.current.name}:${data.aws_caller_identity.current.account_id}:alias/aws/ssm"] + } + ] + }) + + tags = { Name = "${local.tenant}-${local.name}-ssm-read" } +} + +resource "aws_iam_policy" "write" { + name = "${local.tenant}-${local.name}-ssm-write" + description = "Write access to ${local.prefix} parameters" + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Sid = "ManageParameters" + Effect = "Allow" + Action = [ + "ssm:PutParameter", + "ssm:DeleteParameter", + "ssm:GetParameter", + "ssm:GetParameters", + "ssm:GetParametersByPath", + "ssm:DescribeParameters" + ] + Resource = "arn:aws:ssm:${data.aws_region.current.name}:${data.aws_caller_identity.current.account_id}:parameter${local.prefix}/*" + }, + { + Sid = "EncryptDecrypt" + Effect = "Allow" + Action = [ + "kms:Encrypt", + "kms:Decrypt", + "kms:GenerateDataKey" + ] + Resource = local.kms_key_arn != null ? [local.kms_key_arn] : ["arn:aws:kms:${data.aws_region.current.name}:${data.aws_caller_identity.current.account_id}:alias/aws/ssm"] + } + ] + }) + + tags = { Name = "${local.tenant}-${local.name}-ssm-write" } +} + +################################################################################ +# Outputs +################################################################################ + +output "parameter_arns" { + value = { for k, v in aws_ssm_parameter.params : k => v.arn } + description = "Parameter ARNs" +} + +output "parameter_names" { + value = { for k, v in aws_ssm_parameter.params : k => v.name } + description = "Full parameter names (paths)" +} + +output "prefix" { + value = local.prefix + description = "Parameter path prefix" +} + +output "read_policy_arn" { + value = aws_iam_policy.read.arn + description = "IAM policy ARN for reading parameters" +} + +output "write_policy_arn" { + value = aws_iam_policy.write.arn + description = "IAM policy ARN for writing parameters" +} + +output "sdk_examples" { + value = { + get_single = "aws ssm get-parameter --name '${local.prefix}/config/app_name' --query Parameter.Value --output text" + get_secure = "aws ssm get-parameter --name '${local.prefix}/secrets/api_key' --with-decryption --query Parameter.Value --output text" + get_path = "aws ssm get-parameters-by-path --path '${local.prefix}/config' --recursive --query 'Parameters[*].[Name,Value]' --output table" + put_param = "aws ssm put-parameter --name '${local.prefix}/config/new_param' --value 'my-value' --type String --overwrite" + } + description = "Example CLI commands" +} + +output "cost_estimate" { + value = { + standard_params = "Free (up to 10,000 parameters)" + advanced_params = "$0.05/parameter/month" + api_calls = "Free for standard, $0.05 per 10,000 for advanced" + note = "SecureString encryption uses KMS (may have additional costs)" + } + description = "Cost information" +} diff --git a/terraform/05-workloads/_template/static-site/main.tf b/terraform/05-workloads/_template/static-site/main.tf new file mode 100644 index 0000000..c90a977 --- /dev/null +++ b/terraform/05-workloads/_template/static-site/main.tf @@ -0,0 +1,450 @@ +################################################################################ +# Workload: Static Site (S3 + CloudFront) +# +# Deploys a static website: +# - S3 bucket for content (private, OAC access only) +# - CloudFront distribution with HTTPS +# - ACM certificate (DNS validation) +# - WAF integration (optional) +# +# Usage: +# Copy this folder to 05-workloads/-/ +# Update locals and variables +# terraform init -backend-config=../../00-bootstrap/backend.hcl +# terraform apply +################################################################################ + +terraform { + required_version = ">= 1.5" + + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 5.0" + } + } + + backend "s3" { + key = "05-workloads/-/terraform.tfstate" + } +} + +################################################################################ +# Configuration - UPDATE THESE +################################################################################ + +locals { + # Naming + tenant = "" + name = "" + env = "prod" + + # Domain (leave empty for CloudFront default domain) + domain_name = "" # e.g., "www.example.com" + hosted_zone_id = "" # Route53 hosted zone ID + create_certificate = local.domain_name != "" + + # Content settings + default_root_object = "index.html" + error_page_path = "/error.html" + + # Caching + default_ttl = 86400 # 1 day + min_ttl = 0 + max_ttl = 31536000 # 1 year + + # Price class + # PriceClass_100 = US, Canada, Europe (cheapest) + # PriceClass_200 = Above + Asia, Africa, Middle East + # PriceClass_All = All edge locations + price_class = "PriceClass_100" + + # WAF (set to WAF web ACL ARN to enable) + waf_web_acl_arn = "" + + # Logging + enable_logging = true +} + +################################################################################ +# Variables +################################################################################ + +variable "region" { + type = string + default = "us-east-1" +} + +variable "state_bucket" { + type = string +} + +################################################################################ +# Provider +################################################################################ + +provider "aws" { + region = var.region + + default_tags { + tags = { + Tenant = local.tenant + App = local.name + Environment = local.env + ManagedBy = "terraform" + } + } +} + +# ACM certificates must be in us-east-1 for CloudFront +provider "aws" { + alias = "us_east_1" + region = "us-east-1" +} + +################################################################################ +# Data Sources +################################################################################ + +data "terraform_remote_state" "bootstrap" { + backend = "s3" + config = { + bucket = var.state_bucket + key = "00-bootstrap/terraform.tfstate" + region = var.region + } +} + +data "aws_caller_identity" "current" {} +data "aws_region" "current" {} + +################################################################################ +# S3 Bucket +################################################################################ + +resource "aws_s3_bucket" "site" { + bucket = "${local.tenant}-${local.name}-${local.env}-${data.aws_caller_identity.current.account_id}" + + tags = { Name = "${local.tenant}-${local.name}" } +} + +resource "aws_s3_bucket_versioning" "site" { + bucket = aws_s3_bucket.site.id + + versioning_configuration { + status = "Enabled" + } +} + +resource "aws_s3_bucket_server_side_encryption_configuration" "site" { + bucket = aws_s3_bucket.site.id + + rule { + apply_server_side_encryption_by_default { + sse_algorithm = "AES256" + } + } +} + +resource "aws_s3_bucket_public_access_block" "site" { + bucket = aws_s3_bucket.site.id + + block_public_acls = true + block_public_policy = true + ignore_public_acls = true + restrict_public_buckets = true +} + +# Bucket policy for CloudFront OAC +resource "aws_s3_bucket_policy" "site" { + bucket = aws_s3_bucket.site.id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Sid = "AllowCloudFrontOAC" + Effect = "Allow" + Principal = { + Service = "cloudfront.amazonaws.com" + } + Action = "s3:GetObject" + Resource = "${aws_s3_bucket.site.arn}/*" + Condition = { + StringEquals = { + "AWS:SourceArn" = aws_cloudfront_distribution.site.arn + } + } + } + ] + }) +} + +################################################################################ +# CloudFront Origin Access Control +################################################################################ + +resource "aws_cloudfront_origin_access_control" "site" { + name = "${local.tenant}-${local.name}" + description = "OAC for ${local.tenant}-${local.name}" + origin_access_control_origin_type = "s3" + signing_behavior = "always" + signing_protocol = "sigv4" +} + +################################################################################ +# ACM Certificate (if custom domain) +################################################################################ + +resource "aws_acm_certificate" "site" { + count = local.create_certificate ? 1 : 0 + provider = aws.us_east_1 + + domain_name = local.domain_name + validation_method = "DNS" + + lifecycle { + create_before_destroy = true + } + + tags = { Name = "${local.tenant}-${local.name}" } +} + +resource "aws_route53_record" "cert_validation" { + for_each = local.create_certificate ? { + for dvo in aws_acm_certificate.site[0].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 = local.hosted_zone_id +} + +resource "aws_acm_certificate_validation" "site" { + count = local.create_certificate ? 1 : 0 + provider = aws.us_east_1 + + certificate_arn = aws_acm_certificate.site[0].arn + validation_record_fqdns = [for record in aws_route53_record.cert_validation : record.fqdn] +} + +################################################################################ +# CloudFront Logging Bucket +################################################################################ + +resource "aws_s3_bucket" "logs" { + count = local.enable_logging ? 1 : 0 + bucket = "${local.tenant}-${local.name}-logs-${data.aws_caller_identity.current.account_id}" + + tags = { Name = "${local.tenant}-${local.name}-logs" } +} + +resource "aws_s3_bucket_ownership_controls" "logs" { + count = local.enable_logging ? 1 : 0 + bucket = aws_s3_bucket.logs[0].id + + rule { + object_ownership = "BucketOwnerPreferred" + } +} + +resource "aws_s3_bucket_acl" "logs" { + count = local.enable_logging ? 1 : 0 + depends_on = [aws_s3_bucket_ownership_controls.logs] + bucket = aws_s3_bucket.logs[0].id + acl = "private" +} + +resource "aws_s3_bucket_lifecycle_configuration" "logs" { + count = local.enable_logging ? 1 : 0 + bucket = aws_s3_bucket.logs[0].id + + rule { + id = "cleanup" + status = "Enabled" + + expiration { + days = 90 + } + } +} + +################################################################################ +# CloudFront Distribution +################################################################################ + +resource "aws_cloudfront_distribution" "site" { + enabled = true + is_ipv6_enabled = true + default_root_object = local.default_root_object + price_class = local.price_class + comment = "${local.tenant} ${local.name} static site" + + aliases = local.create_certificate ? [local.domain_name] : [] + + origin { + domain_name = aws_s3_bucket.site.bucket_regional_domain_name + origin_id = "S3-${aws_s3_bucket.site.id}" + origin_access_control_id = aws_cloudfront_origin_access_control.site.id + } + + default_cache_behavior { + allowed_methods = ["GET", "HEAD", "OPTIONS"] + cached_methods = ["GET", "HEAD"] + target_origin_id = "S3-${aws_s3_bucket.site.id}" + viewer_protocol_policy = "redirect-to-https" + compress = true + + min_ttl = local.min_ttl + default_ttl = local.default_ttl + max_ttl = local.max_ttl + + forwarded_values { + query_string = false + cookies { + forward = "none" + } + } + + # Security headers + response_headers_policy_id = aws_cloudfront_response_headers_policy.security.id + } + + # Custom error pages + custom_error_response { + error_code = 404 + response_code = 404 + response_page_path = local.error_page_path + error_caching_min_ttl = 60 + } + + custom_error_response { + error_code = 403 + response_code = 404 + response_page_path = local.error_page_path + error_caching_min_ttl = 60 + } + + restrictions { + geo_restriction { + restriction_type = "none" + } + } + + viewer_certificate { + acm_certificate_arn = local.create_certificate ? aws_acm_certificate.site[0].arn : null + ssl_support_method = local.create_certificate ? "sni-only" : null + minimum_protocol_version = local.create_certificate ? "TLSv1.2_2021" : null + cloudfront_default_certificate = !local.create_certificate + } + + dynamic "logging_config" { + for_each = local.enable_logging ? [1] : [] + content { + bucket = aws_s3_bucket.logs[0].bucket_domain_name + include_cookies = false + prefix = "cloudfront/" + } + } + + web_acl_id = local.waf_web_acl_arn != "" ? local.waf_web_acl_arn : null + + tags = { Name = "${local.tenant}-${local.name}" } +} + +################################################################################ +# Security Headers Policy +################################################################################ + +resource "aws_cloudfront_response_headers_policy" "security" { + name = "${local.tenant}-${local.name}-security" + comment = "Security headers for ${local.tenant}-${local.name}" + + security_headers_config { + content_type_options { + override = true + } + + frame_options { + frame_option = "DENY" + override = true + } + + referrer_policy { + referrer_policy = "strict-origin-when-cross-origin" + override = true + } + + strict_transport_security { + access_control_max_age_sec = 31536000 # 1 year + include_subdomains = true + preload = true + override = true + } + + xss_protection { + mode_block = true + protection = true + override = true + } + + content_security_policy { + content_security_policy = "default-src 'self'; img-src 'self' data:; script-src 'self'; style-src 'self' 'unsafe-inline'" + override = true + } + } +} + +################################################################################ +# Route53 Record (if custom domain) +################################################################################ + +resource "aws_route53_record" "site" { + count = local.create_certificate ? 1 : 0 + + zone_id = local.hosted_zone_id + name = local.domain_name + type = "A" + + alias { + name = aws_cloudfront_distribution.site.domain_name + zone_id = aws_cloudfront_distribution.site.hosted_zone_id + evaluate_target_health = false + } +} + +################################################################################ +# Outputs +################################################################################ + +output "bucket_name" { + value = aws_s3_bucket.site.id +} + +output "bucket_arn" { + value = aws_s3_bucket.site.arn +} + +output "distribution_id" { + value = aws_cloudfront_distribution.site.id +} + +output "distribution_domain" { + value = aws_cloudfront_distribution.site.domain_name +} + +output "site_url" { + value = local.create_certificate ? "https://${local.domain_name}" : "https://${aws_cloudfront_distribution.site.domain_name}" +} + +output "deploy_command" { + value = "aws s3 sync ./dist s3://${aws_s3_bucket.site.id} --delete && aws cloudfront create-invalidation --distribution-id ${aws_cloudfront_distribution.site.id} --paths '/*'" + description = "Command to deploy content" +} diff --git a/terraform/05-workloads/_template/step-function/main.tf b/terraform/05-workloads/_template/step-function/main.tf new file mode 100644 index 0000000..d84b830 --- /dev/null +++ b/terraform/05-workloads/_template/step-function/main.tf @@ -0,0 +1,499 @@ +################################################################################ +# Workload: Step Functions State Machine +# +# Deploys a serverless workflow: +# - Step Functions state machine +# - IAM role with least-privilege +# - CloudWatch logging +# - X-Ray tracing +# - EventBridge trigger (optional) +# - API Gateway trigger (optional) +# +# Usage: +# Copy this folder to 05-workloads/-/ +# Update the state machine definition in definition.json +# terraform init -backend-config=../../00-bootstrap/backend.hcl +# terraform apply +################################################################################ + +terraform { + required_version = ">= 1.5" + + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 5.0" + } + } + + backend "s3" { + key = "05-workloads/--workflow/terraform.tfstate" + } +} + +################################################################################ +# Configuration - UPDATE THESE +################################################################################ + +locals { + # Naming + tenant = "" + name = "" + env = "prod" + + state_machine_name = "${local.tenant}-${local.name}-${local.env}" + + # State machine type: STANDARD or EXPRESS + # STANDARD: Long-running (up to 1 year), exactly-once execution + # EXPRESS: Short-duration (up to 5 min), at-least-once, cheaper + type = "STANDARD" + + # Logging level: OFF, ALL, ERROR, FATAL + logging_level = "ERROR" + + # X-Ray tracing + tracing_enabled = true + + # EventBridge trigger (set to null to disable) + schedule_expression = null # e.g., "rate(1 hour)" or "cron(0 12 * * ? *)" + + # API Gateway trigger + enable_api_trigger = false + + # Lambda functions this workflow can invoke (ARNs) + lambda_arns = [ + # "arn:aws:lambda:us-east-1:123456789012:function:my-function", + ] + + # DynamoDB tables this workflow can access (ARNs) + dynamodb_arns = [ + # "arn:aws:dynamodb:us-east-1:123456789012:table/my-table", + ] + + # SQS queues this workflow can send to (ARNs) + sqs_arns = [ + # "arn:aws:sqs:us-east-1:123456789012:my-queue", + ] + + # SNS topics this workflow can publish to (ARNs) + sns_arns = [ + # "arn:aws:sns:us-east-1:123456789012:my-topic", + ] + + # S3 buckets this workflow can access (ARNs) + s3_arns = [ + # "arn:aws:s3:::my-bucket/*", + ] +} + +################################################################################ +# Variables +################################################################################ + +variable "region" { + type = string + default = "us-east-1" +} + +variable "state_bucket" { + type = string +} + +################################################################################ +# Provider +################################################################################ + +provider "aws" { + region = var.region + + default_tags { + tags = { + Tenant = local.tenant + App = local.name + Environment = local.env + ManagedBy = "terraform" + } + } +} + +################################################################################ +# Data Sources +################################################################################ + +data "aws_caller_identity" "current" {} +data "aws_region" "current" {} + +################################################################################ +# CloudWatch Log Group +################################################################################ + +resource "aws_cloudwatch_log_group" "main" { + name = "/aws/states/${local.state_machine_name}" + retention_in_days = 30 + + tags = { Name = local.state_machine_name } +} + +################################################################################ +# IAM Role +################################################################################ + +resource "aws_iam_role" "state_machine" { + name = "${local.state_machine_name}-role" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Effect = "Allow" + Action = "sts:AssumeRole" + Principal = { Service = "states.amazonaws.com" } + }] + }) + + tags = { Name = "${local.state_machine_name}-role" } +} + +# CloudWatch Logs permissions +resource "aws_iam_role_policy" "logs" { + name = "cloudwatch-logs" + role = aws_iam_role.state_machine.id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Effect = "Allow" + Action = [ + "logs:CreateLogDelivery", + "logs:CreateLogStream", + "logs:GetLogDelivery", + "logs:UpdateLogDelivery", + "logs:DeleteLogDelivery", + "logs:ListLogDeliveries", + "logs:PutLogEvents", + "logs:PutResourcePolicy", + "logs:DescribeResourcePolicies", + "logs:DescribeLogGroups" + ] + Resource = "*" + }] + }) +} + +# X-Ray permissions +resource "aws_iam_role_policy" "xray" { + count = local.tracing_enabled ? 1 : 0 + name = "xray" + role = aws_iam_role.state_machine.id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Effect = "Allow" + Action = [ + "xray:PutTraceSegments", + "xray:PutTelemetryRecords", + "xray:GetSamplingRules", + "xray:GetSamplingTargets" + ] + Resource = "*" + }] + }) +} + +# Lambda invocation permissions +resource "aws_iam_role_policy" "lambda" { + count = length(local.lambda_arns) > 0 ? 1 : 0 + name = "lambda" + role = aws_iam_role.state_machine.id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Effect = "Allow" + Action = "lambda:InvokeFunction" + Resource = local.lambda_arns + }] + }) +} + +# DynamoDB permissions +resource "aws_iam_role_policy" "dynamodb" { + count = length(local.dynamodb_arns) > 0 ? 1 : 0 + name = "dynamodb" + role = aws_iam_role.state_machine.id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Effect = "Allow" + Action = [ + "dynamodb:GetItem", + "dynamodb:PutItem", + "dynamodb:UpdateItem", + "dynamodb:DeleteItem", + "dynamodb:Query", + "dynamodb:Scan" + ] + Resource = local.dynamodb_arns + }] + }) +} + +# SQS permissions +resource "aws_iam_role_policy" "sqs" { + count = length(local.sqs_arns) > 0 ? 1 : 0 + name = "sqs" + role = aws_iam_role.state_machine.id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Effect = "Allow" + Action = [ + "sqs:SendMessage", + "sqs:GetQueueUrl" + ] + Resource = local.sqs_arns + }] + }) +} + +# SNS permissions +resource "aws_iam_role_policy" "sns" { + count = length(local.sns_arns) > 0 ? 1 : 0 + name = "sns" + role = aws_iam_role.state_machine.id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Effect = "Allow" + Action = "sns:Publish" + Resource = local.sns_arns + }] + }) +} + +# S3 permissions +resource "aws_iam_role_policy" "s3" { + count = length(local.s3_arns) > 0 ? 1 : 0 + name = "s3" + role = aws_iam_role.state_machine.id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Effect = "Allow" + Action = [ + "s3:GetObject", + "s3:PutObject", + "s3:DeleteObject" + ] + Resource = local.s3_arns + }] + }) +} + +################################################################################ +# State Machine Definition +################################################################################ + +# Simple example - replace with your actual workflow +locals { + state_machine_definition = jsonencode({ + Comment = "Example workflow for ${local.tenant} ${local.name}" + StartAt = "ProcessInput" + States = { + ProcessInput = { + Type = "Pass" + Parameters = { + "input.$" = "$" + "timestamp" = "$$.State.EnteredTime" + } + Next = "Success" + } + Success = { + Type = "Succeed" + } + } + }) +} + +################################################################################ +# Step Functions State Machine +################################################################################ + +resource "aws_sfn_state_machine" "main" { + name = local.state_machine_name + role_arn = aws_iam_role.state_machine.arn + type = local.type + + definition = local.state_machine_definition + + logging_configuration { + log_destination = "${aws_cloudwatch_log_group.main.arn}:*" + include_execution_data = true + level = local.logging_level + } + + tracing_configuration { + enabled = local.tracing_enabled + } + + tags = { Name = local.state_machine_name } +} + +################################################################################ +# EventBridge Schedule Trigger +################################################################################ + +resource "aws_cloudwatch_event_rule" "schedule" { + count = local.schedule_expression != null ? 1 : 0 + name = "${local.state_machine_name}-schedule" + description = "Trigger ${local.state_machine_name} on schedule" + schedule_expression = local.schedule_expression + + tags = { Name = "${local.state_machine_name}-schedule" } +} + +resource "aws_cloudwatch_event_target" "schedule" { + count = local.schedule_expression != null ? 1 : 0 + rule = aws_cloudwatch_event_rule.schedule[0].name + target_id = "StepFunctions" + arn = aws_sfn_state_machine.main.arn + role_arn = aws_iam_role.eventbridge[0].arn + + input = jsonencode({ + source = "scheduled" + timestamp = "$.time" + }) +} + +resource "aws_iam_role" "eventbridge" { + count = local.schedule_expression != null ? 1 : 0 + name = "${local.state_machine_name}-eventbridge" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Effect = "Allow" + Action = "sts:AssumeRole" + Principal = { Service = "events.amazonaws.com" } + }] + }) + + tags = { Name = "${local.state_machine_name}-eventbridge" } +} + +resource "aws_iam_role_policy" "eventbridge" { + count = local.schedule_expression != null ? 1 : 0 + name = "start-execution" + role = aws_iam_role.eventbridge[0].id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Effect = "Allow" + Action = "states:StartExecution" + Resource = aws_sfn_state_machine.main.arn + }] + }) +} + +################################################################################ +# API Gateway Trigger +################################################################################ + +resource "aws_apigatewayv2_api" "main" { + count = local.enable_api_trigger ? 1 : 0 + name = local.state_machine_name + protocol_type = "HTTP" + + tags = { Name = local.state_machine_name } +} + +resource "aws_apigatewayv2_stage" "main" { + count = local.enable_api_trigger ? 1 : 0 + api_id = aws_apigatewayv2_api.main[0].id + name = "$default" + auto_deploy = true +} + +resource "aws_apigatewayv2_integration" "main" { + count = local.enable_api_trigger ? 1 : 0 + api_id = aws_apigatewayv2_api.main[0].id + integration_type = "AWS_PROXY" + integration_subtype = "StepFunctions-StartExecution" + credentials_arn = aws_iam_role.api[0].arn + + request_parameters = { + StateMachineArn = aws_sfn_state_machine.main.arn + Input = "$request.body" + } +} + +resource "aws_apigatewayv2_route" "main" { + count = local.enable_api_trigger ? 1 : 0 + api_id = aws_apigatewayv2_api.main[0].id + route_key = "POST /execute" + target = "integrations/${aws_apigatewayv2_integration.main[0].id}" +} + +resource "aws_iam_role" "api" { + count = local.enable_api_trigger ? 1 : 0 + name = "${local.state_machine_name}-api" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Effect = "Allow" + Action = "sts:AssumeRole" + Principal = { Service = "apigateway.amazonaws.com" } + }] + }) + + tags = { Name = "${local.state_machine_name}-api" } +} + +resource "aws_iam_role_policy" "api" { + count = local.enable_api_trigger ? 1 : 0 + name = "start-execution" + role = aws_iam_role.api[0].id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Effect = "Allow" + Action = "states:StartExecution" + Resource = aws_sfn_state_machine.main.arn + }] + }) +} + +################################################################################ +# Outputs +################################################################################ + +output "state_machine_arn" { + value = aws_sfn_state_machine.main.arn +} + +output "state_machine_name" { + value = aws_sfn_state_machine.main.name +} + +output "role_arn" { + value = aws_iam_role.state_machine.arn +} + +output "log_group" { + value = aws_cloudwatch_log_group.main.name +} + +output "api_endpoint" { + value = local.enable_api_trigger ? "${aws_apigatewayv2_api.main[0].api_endpoint}/execute" : null +} + +output "execution_command" { + value = "aws stepfunctions start-execution --state-machine-arn ${aws_sfn_state_machine.main.arn} --input '{\"key\": \"value\"}'" +} diff --git a/terraform/modules/account-baseline/README.md b/terraform/modules/account-baseline/README.md new file mode 100644 index 0000000..23ffd48 --- /dev/null +++ b/terraform/modules/account-baseline/README.md @@ -0,0 +1,43 @@ +# account-baseline + +Terraform module for AWS landing zone pattern. + +Apply baseline security configuration to AWS accounts in a landing zone. + +## Planned Features + +- [ ] CloudTrail configuration (or org trail delegation) +- [ ] AWS Config (or org aggregator delegation) +- [ ] GuardDuty member enrollment +- [ ] Security Hub member enrollment +- [ ] IAM password policy +- [ ] Standard IAM roles (admin, readonly, billing) +- [ ] EBS default encryption +- [ ] S3 public access block + +## Planned Usage + +```hcl +module "baseline" { + source = "../modules/account-baseline" + + account_name = "workload-prod" + + # Delegate to org-level services + enable_cloudtrail = false + enable_config = false + + # Enroll in delegated admin services + enable_guardduty = true + enable_securityhub = true + + tags = local.tags +} +``` + +## Requirements + +| Name | Version | +|------|---------| +| terraform | >= 1.5.0 | +| aws | >= 5.0 | diff --git a/terraform/modules/account-baseline/main.tf b/terraform/modules/account-baseline/main.tf new file mode 100644 index 0000000..d4248a3 --- /dev/null +++ b/terraform/modules/account-baseline/main.tf @@ -0,0 +1,314 @@ +################################################################################ +# Account Baseline Module +# +# Applies baseline security configuration to AWS accounts: +# - EBS default encryption +# - S3 account public access block +# - IAM account password policy +# - IAM Access Analyzer +# - Security Hub enrollment (optional) +# - GuardDuty enrollment (optional) +################################################################################ + +terraform { + required_version = ">= 1.5.0" + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 5.0" + } + } +} + +data "aws_caller_identity" "current" {} +data "aws_region" "current" {} + +locals { + account_id = data.aws_caller_identity.current.account_id + region = data.aws_region.current.id +} + +################################################################################ +# EBS Default Encryption +################################################################################ + +resource "aws_ebs_encryption_by_default" "this" { + count = var.enable_ebs_encryption ? 1 : 0 + enabled = true +} + +resource "aws_ebs_default_kms_key" "this" { + count = var.enable_ebs_encryption && var.ebs_kms_key_arn != null ? 1 : 0 + key_arn = var.ebs_kms_key_arn +} + +################################################################################ +# S3 Account Public Access Block +################################################################################ + +resource "aws_s3_account_public_access_block" "this" { + count = var.enable_s3_block_public ? 1 : 0 + + block_public_acls = true + block_public_policy = true + ignore_public_acls = true + restrict_public_buckets = true +} + +################################################################################ +# IAM Password Policy +################################################################################ + +resource "aws_iam_account_password_policy" "this" { + count = var.enable_password_policy ? 1 : 0 + + minimum_password_length = var.password_policy.minimum_length + require_lowercase_characters = var.password_policy.require_lowercase + require_numbers = var.password_policy.require_numbers + require_uppercase_characters = var.password_policy.require_uppercase + require_symbols = var.password_policy.require_symbols + allow_users_to_change_password = var.password_policy.allow_users_to_change + max_password_age = var.password_policy.max_age_days + password_reuse_prevention = var.password_policy.reuse_prevention_count + hard_expiry = var.password_policy.hard_expiry +} + +################################################################################ +# IAM Access Analyzer +################################################################################ + +resource "aws_accessanalyzer_analyzer" "this" { + count = var.enable_access_analyzer ? 1 : 0 + + analyzer_name = "${var.name}-access-analyzer" + type = var.access_analyzer_type + + tags = merge(var.tags, { + Name = "${var.name}-access-analyzer" + }) +} + +################################################################################ +# Security Hub +################################################################################ + +resource "aws_securityhub_account" "this" { + count = var.enable_securityhub ? 1 : 0 + + enable_default_standards = var.securityhub_enable_default_standards + auto_enable_controls = var.securityhub_auto_enable_controls + control_finding_generator = "SECURITY_CONTROL" +} + +resource "aws_securityhub_standards_subscription" "this" { + for_each = var.enable_securityhub ? toset(var.securityhub_standards) : [] + + standards_arn = each.value + + depends_on = [aws_securityhub_account.this] +} + +################################################################################ +# GuardDuty +################################################################################ + +resource "aws_guardduty_detector" "this" { + count = var.enable_guardduty ? 1 : 0 + + enable = true + finding_publishing_frequency = var.guardduty_finding_frequency + + datasources { + s3_logs { + enable = true + } + kubernetes { + audit_logs { + enable = var.guardduty_kubernetes_audit + } + } + malware_protection { + scan_ec2_instance_with_findings { + ebs_volumes { + enable = var.guardduty_malware_protection + } + } + } + } + + tags = merge(var.tags, { + Name = "${var.name}-guardduty" + }) +} + +################################################################################ +# AWS Config +################################################################################ + +resource "aws_config_configuration_recorder" "this" { + count = var.enable_config ? 1 : 0 + + name = "${var.name}-config-recorder" + role_arn = aws_iam_role.config[0].arn + + recording_group { + all_supported = true + include_global_resource_types = var.config_include_global_resources + } +} + +resource "aws_config_delivery_channel" "this" { + count = var.enable_config ? 1 : 0 + + name = "${var.name}-config-delivery" + s3_bucket_name = var.config_s3_bucket + s3_key_prefix = var.config_s3_prefix + sns_topic_arn = var.config_sns_topic_arn + + snapshot_delivery_properties { + delivery_frequency = var.config_snapshot_frequency + } + + depends_on = [aws_config_configuration_recorder.this] +} + +resource "aws_config_configuration_recorder_status" "this" { + count = var.enable_config ? 1 : 0 + + name = aws_config_configuration_recorder.this[0].name + is_enabled = true + + depends_on = [aws_config_delivery_channel.this] +} + +resource "aws_iam_role" "config" { + count = var.enable_config ? 1 : 0 + + name = "${var.name}-config-role" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Action = "sts:AssumeRole" + Effect = "Allow" + Principal = { + Service = "config.amazonaws.com" + } + }] + }) + + tags = merge(var.tags, { + Name = "${var.name}-config-role" + }) +} + +resource "aws_iam_role_policy_attachment" "config" { + count = var.enable_config ? 1 : 0 + + role = aws_iam_role.config[0].name + policy_arn = "arn:aws:iam::aws:policy/service-role/AWS_ConfigRole" +} + +resource "aws_iam_role_policy" "config_s3" { + count = var.enable_config ? 1 : 0 + + name = "config-s3-access" + role = aws_iam_role.config[0].id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Effect = "Allow" + Action = [ + "s3:PutObject", + "s3:PutObjectAcl" + ] + Resource = "arn:aws:s3:::${var.config_s3_bucket}/${var.config_s3_prefix}/*" + Condition = { + StringEquals = { + "s3:x-amz-acl" = "bucket-owner-full-control" + } + } + }, + { + Effect = "Allow" + Action = "s3:GetBucketAcl" + Resource = "arn:aws:s3:::${var.config_s3_bucket}" + } + ] + }) +} + +################################################################################ +# Standard IAM Roles +################################################################################ + +resource "aws_iam_role" "admin" { + count = var.create_admin_role ? 1 : 0 + + name = "${var.name}-admin" + path = var.iam_role_path + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Action = "sts:AssumeRole" + Effect = "Allow" + Principal = { + AWS = var.trusted_admin_principals + } + Condition = var.require_mfa ? { + Bool = { + "aws:MultiFactorAuthPresent" = "true" + } + } : {} + }] + }) + + max_session_duration = var.admin_session_duration + + tags = merge(var.tags, { + Name = "${var.name}-admin" + Role = "admin" + }) +} + +resource "aws_iam_role_policy_attachment" "admin" { + count = var.create_admin_role ? 1 : 0 + + role = aws_iam_role.admin[0].name + policy_arn = "arn:aws:iam::aws:policy/AdministratorAccess" +} + +resource "aws_iam_role" "readonly" { + count = var.create_readonly_role ? 1 : 0 + + name = "${var.name}-readonly" + path = var.iam_role_path + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Action = "sts:AssumeRole" + Effect = "Allow" + Principal = { + AWS = var.trusted_readonly_principals + } + }] + }) + + max_session_duration = var.readonly_session_duration + + tags = merge(var.tags, { + Name = "${var.name}-readonly" + Role = "readonly" + }) +} + +resource "aws_iam_role_policy_attachment" "readonly" { + count = var.create_readonly_role ? 1 : 0 + + role = aws_iam_role.readonly[0].name + policy_arn = "arn:aws:iam::aws:policy/ReadOnlyAccess" +} diff --git a/terraform/modules/account-baseline/outputs.tf b/terraform/modules/account-baseline/outputs.tf new file mode 100644 index 0000000..8e5cabc --- /dev/null +++ b/terraform/modules/account-baseline/outputs.tf @@ -0,0 +1,56 @@ +################################################################################ +# Account Baseline - Outputs +################################################################################ + +output "ebs_encryption_enabled" { + value = var.enable_ebs_encryption + description = "Whether EBS encryption is enabled" +} + +output "s3_block_public_enabled" { + value = var.enable_s3_block_public + description = "Whether S3 public block is enabled" +} + +output "access_analyzer_arn" { + value = try(aws_accessanalyzer_analyzer.this[0].arn, null) + description = "Access Analyzer ARN" +} + +output "securityhub_enabled" { + value = var.enable_securityhub + description = "Whether Security Hub is enabled" +} + +output "guardduty_detector_id" { + value = try(aws_guardduty_detector.this[0].id, null) + description = "GuardDuty detector ID" +} + +output "config_recorder_id" { + value = try(aws_config_configuration_recorder.this[0].id, null) + description = "Config recorder ID" +} + +output "admin_role_arn" { + value = try(aws_iam_role.admin[0].arn, null) + description = "Admin IAM role ARN" +} + +output "readonly_role_arn" { + value = try(aws_iam_role.readonly[0].arn, null) + description = "Readonly IAM role ARN" +} + +output "baseline_status" { + value = { + ebs_encryption = var.enable_ebs_encryption + s3_block_public = var.enable_s3_block_public + password_policy = var.enable_password_policy + access_analyzer = var.enable_access_analyzer + securityhub = var.enable_securityhub + guardduty = var.enable_guardduty + config = var.enable_config + } + description = "Summary of baseline status" +} diff --git a/terraform/modules/account-baseline/variables.tf b/terraform/modules/account-baseline/variables.tf new file mode 100644 index 0000000..f6a00c6 --- /dev/null +++ b/terraform/modules/account-baseline/variables.tf @@ -0,0 +1,206 @@ +################################################################################ +# Account Baseline - Input Variables +################################################################################ + +variable "name" { + type = string + description = "Name prefix for resources" +} + +# EBS Encryption +variable "enable_ebs_encryption" { + type = bool + default = true + description = "Enable EBS encryption by default" +} + +variable "ebs_kms_key_arn" { + type = string + default = null + description = "KMS key ARN for EBS encryption (null = AWS managed)" +} + +# S3 Public Access +variable "enable_s3_block_public" { + type = bool + default = true + description = "Block public access to S3 at account level" +} + +# Password Policy +variable "enable_password_policy" { + type = bool + default = true + description = "Configure IAM password policy" +} + +variable "password_policy" { + type = object({ + minimum_length = optional(number, 14) + require_lowercase = optional(bool, true) + require_uppercase = optional(bool, true) + require_numbers = optional(bool, true) + require_symbols = optional(bool, true) + allow_users_to_change = optional(bool, true) + max_age_days = optional(number, 90) + reuse_prevention_count = optional(number, 24) + hard_expiry = optional(bool, false) + }) + default = {} + description = "IAM password policy settings" +} + +# Access Analyzer +variable "enable_access_analyzer" { + type = bool + default = true + description = "Enable IAM Access Analyzer" +} + +variable "access_analyzer_type" { + type = string + default = "ACCOUNT" + description = "Access Analyzer type (ACCOUNT or ORGANIZATION)" +} + +# Security Hub +variable "enable_securityhub" { + type = bool + default = false + description = "Enable Security Hub (set false if using delegated admin)" +} + +variable "securityhub_enable_default_standards" { + type = bool + default = false + description = "Enable default Security Hub standards" +} + +variable "securityhub_auto_enable_controls" { + type = bool + default = true + description = "Auto-enable new controls" +} + +variable "securityhub_standards" { + type = list(string) + default = [] + description = "Security Hub standard ARNs to enable" +} + +# GuardDuty +variable "enable_guardduty" { + type = bool + default = false + description = "Enable GuardDuty (set false if using delegated admin)" +} + +variable "guardduty_finding_frequency" { + type = string + default = "FIFTEEN_MINUTES" + description = "GuardDuty finding publishing frequency" +} + +variable "guardduty_kubernetes_audit" { + type = bool + default = true + description = "Enable GuardDuty Kubernetes audit logs" +} + +variable "guardduty_malware_protection" { + type = bool + default = true + description = "Enable GuardDuty malware protection" +} + +# AWS Config +variable "enable_config" { + type = bool + default = false + description = "Enable AWS Config (set false if using org aggregator)" +} + +variable "config_s3_bucket" { + type = string + default = "" + description = "S3 bucket for Config recordings" +} + +variable "config_s3_prefix" { + type = string + default = "config" + description = "S3 key prefix for Config recordings" +} + +variable "config_sns_topic_arn" { + type = string + default = null + description = "SNS topic for Config notifications" +} + +variable "config_snapshot_frequency" { + type = string + default = "TwentyFour_Hours" + description = "Config snapshot delivery frequency" +} + +variable "config_include_global_resources" { + type = bool + default = true + description = "Include global resources in Config" +} + +# IAM Roles +variable "create_admin_role" { + type = bool + default = false + description = "Create admin IAM role" +} + +variable "create_readonly_role" { + type = bool + default = false + description = "Create readonly IAM role" +} + +variable "iam_role_path" { + type = string + default = "/" + description = "IAM role path" +} + +variable "trusted_admin_principals" { + type = list(string) + default = [] + description = "ARNs allowed to assume admin role" +} + +variable "trusted_readonly_principals" { + type = list(string) + default = [] + description = "ARNs allowed to assume readonly role" +} + +variable "require_mfa" { + type = bool + default = true + description = "Require MFA for admin role assumption" +} + +variable "admin_session_duration" { + type = number + default = 3600 + description = "Admin role session duration in seconds" +} + +variable "readonly_session_duration" { + type = number + default = 3600 + description = "Readonly role session duration in seconds" +} + +variable "tags" { + type = map(string) + default = {} + description = "Tags to apply to resources" +} diff --git a/terraform/modules/acm-certificate/README.md b/terraform/modules/acm-certificate/README.md new file mode 100644 index 0000000..2f2c0df --- /dev/null +++ b/terraform/modules/acm-certificate/README.md @@ -0,0 +1,49 @@ +# acm-certificate + +ACM Certificate Module + +## Usage + +```hcl +module "acm_certificate" { + source = "../modules/acm-certificate" + + # Required variables + domain_name = "" + + # Optional: see variables.tf for all options +} +``` + +## Requirements + +| Name | Version | +|------|---------| +| terraform | >= 1.5.0 | +| aws | >= 5.0 | + +## Inputs + +| Name | Description | Type | Required | +|------|-------------|------|----------| +| domain_name | Primary domain name for the certificate | `string` | yes | +| subject_alternative_names | Additional domain names (SANs) for the certificate | `list(string)` | no | +| zone_id | Route53 zone ID for DNS validation (null for email validatio... | `string` | no | +| validation_method | Validation method: DNS or EMAIL | `string` | no | +| wait_for_validation | Wait for certificate validation to complete | `bool` | no | +| validation_timeout | Timeout for certificate validation | `string` | no | +| tags | | `map(string)` | no | + +## Outputs + +| Name | Description | +|------|-------------| +| certificate_arn | ARN of the certificate | +| certificate_domain_name | Primary domain name | +| certificate_status | Certificate status | +| validation_records | | +| validated_certificate_arn | ARN of the validated certificate | + +## License + +Apache 2.0 - See LICENSE for details. diff --git a/terraform/modules/acm-certificate/main.tf b/terraform/modules/acm-certificate/main.tf new file mode 100644 index 0000000..3857def --- /dev/null +++ b/terraform/modules/acm-certificate/main.tf @@ -0,0 +1,163 @@ +################################################################################ +# ACM Certificate Module +# +# SSL/TLS certificates with: +# - DNS or email validation +# - Automatic Route53 validation records +# - SAN (Subject Alternative Names) support +# - Wildcard certificates +# +# Usage: +# module "cert" { +# source = "../modules/acm-certificate" +# +# domain_name = "example.com" +# zone_id = "Z1234567890" +# +# subject_alternative_names = [ +# "*.example.com", +# "api.example.com" +# ] +# } +################################################################################ + +terraform { + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 5.0" + } + } +} + +variable "domain_name" { + type = string + description = "Primary domain name for the certificate" +} + +variable "subject_alternative_names" { + type = list(string) + default = [] + description = "Additional domain names (SANs) for the certificate" +} + +variable "zone_id" { + type = string + default = null + description = "Route53 zone ID for DNS validation (null for email validation)" +} + +variable "validation_method" { + type = string + default = "DNS" + description = "Validation method: DNS or EMAIL" + + validation { + condition = contains(["DNS", "EMAIL"], var.validation_method) + error_message = "Must be DNS or EMAIL" + } +} + +variable "wait_for_validation" { + type = bool + default = true + description = "Wait for certificate validation to complete" +} + +variable "validation_timeout" { + type = string + default = "45m" + description = "Timeout for certificate validation" +} + +variable "tags" { + type = map(string) + default = {} +} + +################################################################################ +# ACM Certificate +################################################################################ + +resource "aws_acm_certificate" "main" { + domain_name = var.domain_name + subject_alternative_names = var.subject_alternative_names + validation_method = var.validation_method + + lifecycle { + create_before_destroy = true + } + + tags = merge(var.tags, { Name = var.domain_name }) +} + +################################################################################ +# DNS Validation Records +################################################################################ + +resource "aws_route53_record" "validation" { + for_each = var.validation_method == "DNS" && var.zone_id != null ? { + for dvo in aws_acm_certificate.main.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.zone_id +} + +################################################################################ +# Certificate Validation +################################################################################ + +resource "aws_acm_certificate_validation" "main" { + count = var.wait_for_validation ? 1 : 0 + + certificate_arn = aws_acm_certificate.main.arn + validation_record_fqdns = var.validation_method == "DNS" && var.zone_id != null ? [for record in aws_route53_record.validation : record.fqdn] : null + + timeouts { + create = var.validation_timeout + } +} + +################################################################################ +# Outputs +################################################################################ + +output "certificate_arn" { + value = aws_acm_certificate.main.arn + description = "ARN of the certificate" +} + +output "certificate_domain_name" { + value = aws_acm_certificate.main.domain_name + description = "Primary domain name" +} + +output "certificate_status" { + value = aws_acm_certificate.main.status + description = "Certificate status" +} + +output "validation_records" { + value = var.validation_method == "DNS" ? { + for dvo in aws_acm_certificate.main.domain_validation_options : dvo.domain_name => { + name = dvo.resource_record_name + type = dvo.resource_record_type + value = dvo.resource_record_value + } + } : null + description = "DNS validation records (if using DNS validation without auto Route53)" +} + +output "validated_certificate_arn" { + value = var.wait_for_validation ? aws_acm_certificate_validation.main[0].certificate_arn : aws_acm_certificate.main.arn + description = "ARN of the validated certificate" +} diff --git a/terraform/modules/alb/README.md b/terraform/modules/alb/README.md new file mode 100644 index 0000000..2ceb986 --- /dev/null +++ b/terraform/modules/alb/README.md @@ -0,0 +1,68 @@ +# alb + +Application Load Balancer Module + +## Usage + +```hcl +module "alb" { + source = "../modules/alb" + + # Required variables + name = "" + vpc_id = "" + subnet_ids = "" + access_logs = "" + target_groups = "" + listener_rules = "" + + # Optional: see variables.tf for all options +} +``` + +## Requirements + +| Name | Version | +|------|---------| +| terraform | >= 1.5.0 | +| aws | >= 5.0 | + +## Inputs + +| Name | Description | Type | Required | +|------|-------------|------|----------| +| name | ALB name | `string` | yes | +| vpc_id | VPC ID | `string` | yes | +| subnet_ids | Subnet IDs (public for internet-facing, private for internal... | `list(string)` | yes | +| internal | Internal ALB (no public IP) | `bool` | no | +| certificate_arn | ACM certificate ARN for HTTPS | `string` | no | +| additional_certificates | Additional certificate ARNs for SNI | `list(string)` | no | +| ssl_policy | SSL policy for HTTPS listeners | `string` | no | +| enable_deletion_protection | Prevent accidental deletion | `bool` | no | +| enable_http2 | Enable HTTP/2 | `bool` | no | +| idle_timeout | Idle timeout in seconds | `number` | no | +| drop_invalid_header_fields | Drop requests with invalid headers | `bool` | no | +| access_logs | | `object({` | yes | +| target_groups | | `map(object({` | yes | +| listener_rules | | `map(object({` | yes | +| waf_arn | WAF Web ACL ARN to associate | `string` | no | + +*...and 3 more variables. See `variables.tf` for complete list.* + +## Outputs + +| Name | Description | +|------|-------------| +| arn | ALB ARN | +| arn_suffix | ALB ARN suffix (for CloudWatch metrics) | +| dns_name | ALB DNS name | +| zone_id | ALB hosted zone ID | +| security_group_id | ALB security group ID | +| target_group_arns | | +| target_group_arn_suffixes | | +| https_listener_arn | HTTPS listener ARN | +| http_listener_arn | HTTP listener ARN | + +## License + +Apache 2.0 - See LICENSE for details. diff --git a/terraform/modules/alb/main.tf b/terraform/modules/alb/main.tf new file mode 100644 index 0000000..482a6b4 --- /dev/null +++ b/terraform/modules/alb/main.tf @@ -0,0 +1,497 @@ +################################################################################ +# Application Load Balancer Module +# +# Full-featured ALB with: +# - HTTPS with ACM certificate +# - HTTP to HTTPS redirect +# - Access logging to S3 +# - WAF integration (optional) +# - Multiple target groups +# - Host/path-based routing +# - Health checks +# +# Usage: +# module "alb" { +# source = "../modules/alb" +# +# name = "web-alb" +# vpc_id = module.vpc.vpc_id +# subnet_ids = module.vpc.public_subnet_ids +# +# certificate_arn = module.acm.certificate_arn +# +# target_groups = { +# api = { +# port = 8080 +# protocol = "HTTP" +# target_type = "ip" +# health_check_path = "/health" +# } +# } +# } +################################################################################ + +terraform { + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 5.0" + } + } +} + +variable "name" { + type = string + description = "ALB name" +} + +variable "vpc_id" { + type = string + description = "VPC ID" +} + +variable "subnet_ids" { + type = list(string) + description = "Subnet IDs (public for internet-facing, private for internal)" +} + +variable "internal" { + type = bool + default = false + description = "Internal ALB (no public IP)" +} + +variable "certificate_arn" { + type = string + default = "" + description = "ACM certificate ARN for HTTPS" +} + +variable "additional_certificates" { + type = list(string) + default = [] + description = "Additional certificate ARNs for SNI" +} + +variable "ssl_policy" { + type = string + default = "ELBSecurityPolicy-TLS13-1-2-2021-06" + description = "SSL policy for HTTPS listeners" +} + +variable "enable_deletion_protection" { + type = bool + default = true + description = "Prevent accidental deletion" +} + +variable "enable_http2" { + type = bool + default = true + description = "Enable HTTP/2" +} + +variable "idle_timeout" { + type = number + default = 60 + description = "Idle timeout in seconds" +} + +variable "drop_invalid_header_fields" { + type = bool + default = true + description = "Drop requests with invalid headers" +} + +variable "access_logs" { + type = object({ + enabled = bool + bucket = string + prefix = optional(string, "") + }) + default = { + enabled = false + bucket = "" + } + description = "Access logging configuration" +} + +variable "target_groups" { + type = map(object({ + port = number + protocol = optional(string, "HTTP") + target_type = optional(string, "ip") + deregistration_delay = optional(number, 30) + slow_start = optional(number, 0) + + health_check_path = optional(string, "/") + health_check_port = optional(string, "traffic-port") + health_check_protocol = optional(string, "HTTP") + health_check_interval = optional(number, 30) + health_check_timeout = optional(number, 5) + healthy_threshold = optional(number, 2) + unhealthy_threshold = optional(number, 3) + health_check_matcher = optional(string, "200-299") + + stickiness_enabled = optional(bool, false) + stickiness_duration = optional(number, 86400) + })) + default = {} + description = "Target group configurations" +} + +variable "listener_rules" { + type = map(object({ + priority = number + target_group_key = string + + # Conditions (at least one required) + host_headers = optional(list(string), []) + path_patterns = optional(list(string), []) + http_headers = optional(map(list(string)), {}) + query_strings = optional(map(string), {}) + source_ips = optional(list(string), []) + })) + default = {} + description = "HTTPS listener rules for routing" +} + +variable "waf_arn" { + type = string + default = "" + description = "WAF Web ACL ARN to associate" +} + +variable "security_group_ids" { + type = list(string) + default = [] + description = "Additional security group IDs" +} + +variable "ingress_cidr_blocks" { + type = list(string) + default = ["0.0.0.0/0"] + description = "CIDR blocks for ingress (HTTP/HTTPS)" +} + +variable "tags" { + type = map(string) + default = {} +} + +################################################################################ +# Security Group +################################################################################ + +resource "aws_security_group" "alb" { + name = "${var.name}-alb" + description = "ALB security group" + vpc_id = var.vpc_id + + ingress { + description = "HTTPS" + from_port = 443 + to_port = 443 + protocol = "tcp" + cidr_blocks = var.ingress_cidr_blocks + } + + ingress { + description = "HTTP (redirect)" + from_port = 80 + to_port = 80 + protocol = "tcp" + cidr_blocks = var.ingress_cidr_blocks + } + + egress { + description = "All outbound" + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + } + + tags = merge(var.tags, { Name = "${var.name}-alb" }) +} + +################################################################################ +# Application Load Balancer +################################################################################ + +resource "aws_lb" "main" { + name = var.name + internal = var.internal + load_balancer_type = "application" + security_groups = concat([aws_security_group.alb.id], var.security_group_ids) + subnets = var.subnet_ids + + enable_deletion_protection = var.enable_deletion_protection + enable_http2 = var.enable_http2 + idle_timeout = var.idle_timeout + drop_invalid_header_fields = var.drop_invalid_header_fields + + dynamic "access_logs" { + for_each = var.access_logs.enabled ? [1] : [] + content { + bucket = var.access_logs.bucket + prefix = var.access_logs.prefix + enabled = true + } + } + + tags = merge(var.tags, { Name = var.name }) +} + +################################################################################ +# Target Groups +################################################################################ + +resource "aws_lb_target_group" "main" { + for_each = var.target_groups + + name = "${var.name}-${each.key}" + port = each.value.port + protocol = each.value.protocol + vpc_id = var.vpc_id + target_type = each.value.target_type + deregistration_delay = each.value.deregistration_delay + slow_start = each.value.slow_start + + health_check { + enabled = true + path = each.value.health_check_path + port = each.value.health_check_port + protocol = each.value.health_check_protocol + interval = each.value.health_check_interval + timeout = each.value.health_check_timeout + healthy_threshold = each.value.healthy_threshold + unhealthy_threshold = each.value.unhealthy_threshold + matcher = each.value.health_check_matcher + } + + dynamic "stickiness" { + for_each = each.value.stickiness_enabled ? [1] : [] + content { + type = "lb_cookie" + cookie_duration = each.value.stickiness_duration + enabled = true + } + } + + tags = merge(var.tags, { Name = "${var.name}-${each.key}" }) + + lifecycle { + create_before_destroy = true + } +} + +################################################################################ +# HTTPS Listener +################################################################################ + +resource "aws_lb_listener" "https" { + count = var.certificate_arn != "" ? 1 : 0 + + load_balancer_arn = aws_lb.main.arn + port = 443 + protocol = "HTTPS" + ssl_policy = var.ssl_policy + certificate_arn = var.certificate_arn + + default_action { + type = length(var.target_groups) > 0 ? "forward" : "fixed-response" + + dynamic "forward" { + for_each = length(var.target_groups) > 0 ? [1] : [] + content { + target_group { + arn = aws_lb_target_group.main[keys(var.target_groups)[0]].arn + } + } + } + + dynamic "fixed_response" { + for_each = length(var.target_groups) == 0 ? [1] : [] + content { + content_type = "text/plain" + message_body = "No backend configured" + status_code = "503" + } + } + } + + tags = merge(var.tags, { Name = "${var.name}-https" }) +} + +# Additional certificates (SNI) +resource "aws_lb_listener_certificate" "additional" { + for_each = toset(var.additional_certificates) + + listener_arn = aws_lb_listener.https[0].arn + certificate_arn = each.value +} + +################################################################################ +# HTTP Listener (Redirect to HTTPS) +################################################################################ + +resource "aws_lb_listener" "http" { + load_balancer_arn = aws_lb.main.arn + port = 80 + protocol = "HTTP" + + default_action { + type = var.certificate_arn != "" ? "redirect" : "forward" + + dynamic "redirect" { + for_each = var.certificate_arn != "" ? [1] : [] + content { + port = "443" + protocol = "HTTPS" + status_code = "HTTP_301" + } + } + + dynamic "forward" { + for_each = var.certificate_arn == "" && length(var.target_groups) > 0 ? [1] : [] + content { + target_group { + arn = aws_lb_target_group.main[keys(var.target_groups)[0]].arn + } + } + } + } + + tags = merge(var.tags, { Name = "${var.name}-http" }) +} + +################################################################################ +# Listener Rules +################################################################################ + +resource "aws_lb_listener_rule" "main" { + for_each = var.certificate_arn != "" ? var.listener_rules : {} + + listener_arn = aws_lb_listener.https[0].arn + priority = each.value.priority + + action { + type = "forward" + target_group_arn = aws_lb_target_group.main[each.value.target_group_key].arn + } + + # Host header condition + dynamic "condition" { + for_each = length(each.value.host_headers) > 0 ? [1] : [] + content { + host_header { + values = each.value.host_headers + } + } + } + + # Path pattern condition + dynamic "condition" { + for_each = length(each.value.path_patterns) > 0 ? [1] : [] + content { + path_pattern { + values = each.value.path_patterns + } + } + } + + # HTTP header conditions + dynamic "condition" { + for_each = each.value.http_headers + content { + http_header { + http_header_name = condition.key + values = condition.value + } + } + } + + # Query string conditions + dynamic "condition" { + for_each = each.value.query_strings + content { + query_string { + key = condition.key + value = condition.value + } + } + } + + # Source IP condition + dynamic "condition" { + for_each = length(each.value.source_ips) > 0 ? [1] : [] + content { + source_ip { + values = each.value.source_ips + } + } + } + + tags = merge(var.tags, { Name = "${var.name}-${each.key}" }) +} + +################################################################################ +# WAF Association +################################################################################ + +resource "aws_wafv2_web_acl_association" "main" { + count = var.waf_arn != "" ? 1 : 0 + + resource_arn = aws_lb.main.arn + web_acl_arn = var.waf_arn +} + +################################################################################ +# Outputs +################################################################################ + +output "arn" { + value = aws_lb.main.arn + description = "ALB ARN" +} + +output "arn_suffix" { + value = aws_lb.main.arn_suffix + description = "ALB ARN suffix (for CloudWatch metrics)" +} + +output "dns_name" { + value = aws_lb.main.dns_name + description = "ALB DNS name" +} + +output "zone_id" { + value = aws_lb.main.zone_id + description = "ALB hosted zone ID" +} + +output "security_group_id" { + value = aws_security_group.alb.id + description = "ALB security group ID" +} + +output "target_group_arns" { + value = { for k, v in aws_lb_target_group.main : k => v.arn } + description = "Target group ARNs" +} + +output "target_group_arn_suffixes" { + value = { for k, v in aws_lb_target_group.main : k => v.arn_suffix } + description = "Target group ARN suffixes" +} + +output "https_listener_arn" { + value = length(aws_lb_listener.https) > 0 ? aws_lb_listener.https[0].arn : null + description = "HTTPS listener ARN" +} + +output "http_listener_arn" { + value = aws_lb_listener.http.arn + description = "HTTP listener ARN" +} diff --git a/terraform/modules/alerting/README.md b/terraform/modules/alerting/README.md new file mode 100644 index 0000000..588bb17 --- /dev/null +++ b/terraform/modules/alerting/README.md @@ -0,0 +1,50 @@ +# alerting + +Alerting Module + +## Usage + +```hcl +module "alerting" { + source = "../modules/alerting" + + # Required variables + name = "" + + # Optional: see variables.tf for all options +} +``` + +## Requirements + +| Name | Version | +|------|---------| +| terraform | >= 1.5.0 | +| aws | >= 5.0 | + +## Inputs + +| Name | Description | Type | Required | +|------|-------------|------|----------| +| name | Name prefix for alerting resources | `string` | yes | +| email_endpoints | Email addresses to receive alerts | `list(string)` | no | +| email_endpoints_critical | Email addresses for critical alerts only (uses email_endpoin... | `list(string)` | no | +| slack_webhook_url | Slack webhook URL for notifications | `string` | no | +| pagerduty_endpoint | PagerDuty Events API endpoint | `string` | no | +| enable_aws_health_events | | `bool` | no | +| enable_guardduty_events | | `bool` | no | +| enable_securityhub_events | | `bool` | no | +| tags | | `map(string)` | no | + +## Outputs + +| Name | Description | +|------|-------------| +| critical_topic_arn | SNS topic for critical alerts | +| warning_topic_arn | SNS topic for warning alerts | +| info_topic_arn | SNS topic for info alerts | +| topics | | + +## License + +Apache 2.0 - See LICENSE for details. diff --git a/terraform/modules/alerting/main.tf b/terraform/modules/alerting/main.tf new file mode 100644 index 0000000..13ee3eb --- /dev/null +++ b/terraform/modules/alerting/main.tf @@ -0,0 +1,429 @@ +################################################################################ +# Alerting Module +# +# Centralized alerting infrastructure: +# - SNS topics by severity (critical, warning, info) +# - Subscriptions (email, Slack, PagerDuty) +# - CloudWatch composite alarms +# - EventBridge rules for AWS events +# +# Usage: +# module "alerting" { +# source = "../modules/alerting" +# name = "myproject-prod" +# +# email_endpoints = ["ops@example.com"] +# slack_webhook_url = "https://hooks.slack.com/..." +# } +################################################################################ + +terraform { + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 5.0" + } + } +} + +variable "name" { + type = string + description = "Name prefix for alerting resources" +} + +variable "email_endpoints" { + type = list(string) + default = [] + description = "Email addresses to receive alerts" +} + +variable "email_endpoints_critical" { + type = list(string) + default = [] + description = "Email addresses for critical alerts only (uses email_endpoints if empty)" +} + +variable "slack_webhook_url" { + type = string + default = "" + description = "Slack webhook URL for notifications" + sensitive = true +} + +variable "pagerduty_endpoint" { + type = string + default = "" + description = "PagerDuty Events API endpoint" + sensitive = true +} + +variable "enable_aws_health_events" { + type = bool + default = true +} + +variable "enable_guardduty_events" { + type = bool + default = true +} + +variable "enable_securityhub_events" { + type = bool + default = true +} + +variable "tags" { + type = map(string) + default = {} +} + +data "aws_caller_identity" "current" {} +data "aws_region" "current" {} + +################################################################################ +# SNS Topics by Severity +################################################################################ + +resource "aws_sns_topic" "critical" { + name = "${var.name}-alerts-critical" + kms_master_key_id = "alias/aws/sns" + + tags = merge(var.tags, { Name = "${var.name}-critical", Severity = "critical" }) +} + +resource "aws_sns_topic" "warning" { + name = "${var.name}-alerts-warning" + kms_master_key_id = "alias/aws/sns" + + tags = merge(var.tags, { Name = "${var.name}-warning", Severity = "warning" }) +} + +resource "aws_sns_topic" "info" { + name = "${var.name}-alerts-info" + kms_master_key_id = "alias/aws/sns" + + tags = merge(var.tags, { Name = "${var.name}-info", Severity = "info" }) +} + +################################################################################ +# SNS Topic Policies +################################################################################ + +data "aws_iam_policy_document" "sns_policy" { + statement { + sid = "AllowCloudWatchAlarms" + effect = "Allow" + + principals { + type = "Service" + identifiers = ["cloudwatch.amazonaws.com"] + } + + actions = ["sns:Publish"] + resources = ["*"] + + condition { + test = "ArnLike" + variable = "aws:SourceArn" + values = ["arn:aws:cloudwatch:${data.aws_region.current.name}:${data.aws_caller_identity.current.account_id}:alarm:*"] + } + } + + statement { + sid = "AllowEventBridge" + effect = "Allow" + + principals { + type = "Service" + identifiers = ["events.amazonaws.com"] + } + + actions = ["sns:Publish"] + resources = ["*"] + } +} + +resource "aws_sns_topic_policy" "critical" { + arn = aws_sns_topic.critical.arn + policy = data.aws_iam_policy_document.sns_policy.json +} + +resource "aws_sns_topic_policy" "warning" { + arn = aws_sns_topic.warning.arn + policy = data.aws_iam_policy_document.sns_policy.json +} + +resource "aws_sns_topic_policy" "info" { + arn = aws_sns_topic.info.arn + policy = data.aws_iam_policy_document.sns_policy.json +} + +################################################################################ +# Email Subscriptions +################################################################################ + +resource "aws_sns_topic_subscription" "critical_email" { + for_each = toset(length(var.email_endpoints_critical) > 0 ? var.email_endpoints_critical : var.email_endpoints) + + topic_arn = aws_sns_topic.critical.arn + protocol = "email" + endpoint = each.value +} + +resource "aws_sns_topic_subscription" "warning_email" { + for_each = toset(var.email_endpoints) + + topic_arn = aws_sns_topic.warning.arn + protocol = "email" + endpoint = each.value +} + +################################################################################ +# Slack Integration (via Lambda) +################################################################################ + +data "archive_file" "slack_notifier" { + count = var.slack_webhook_url != "" ? 1 : 0 + type = "zip" + output_path = "${path.module}/slack_notifier.zip" + + source { + content = <<-PYTHON +import json +import urllib.request +import os + +def handler(event, context): + webhook_url = os.environ['SLACK_WEBHOOK_URL'] + + for record in event.get('Records', []): + message = json.loads(record['Sns']['Message']) + + # Parse CloudWatch Alarm + if 'AlarmName' in message: + color = '#FF0000' if message['NewStateValue'] == 'ALARM' else '#36a64f' + text = f"*{message['AlarmName']}*\n{message['AlarmDescription']}\n\nState: {message['NewStateValue']}\nReason: {message['NewStateReason']}" + else: + text = json.dumps(message, indent=2) + color = '#FFA500' + + payload = { + 'attachments': [{ + 'color': color, + 'text': text, + 'footer': f"AWS | {message.get('Region', 'Unknown Region')}", + }] + } + + req = urllib.request.Request( + webhook_url, + data=json.dumps(payload).encode('utf-8'), + headers={'Content-Type': 'application/json'} + ) + urllib.request.urlopen(req) + + return {'statusCode': 200} +PYTHON + filename = "lambda_function.py" + } +} + +resource "aws_lambda_function" "slack_notifier" { + count = var.slack_webhook_url != "" ? 1 : 0 + filename = data.archive_file.slack_notifier[0].output_path + source_code_hash = data.archive_file.slack_notifier[0].output_base64sha256 + function_name = "${var.name}-slack-notifier" + role = aws_iam_role.slack_notifier[0].arn + handler = "lambda_function.handler" + runtime = "python3.12" + timeout = 30 + + environment { + variables = { + SLACK_WEBHOOK_URL = var.slack_webhook_url + } + } + + tags = merge(var.tags, { Name = "${var.name}-slack-notifier" }) +} + +resource "aws_iam_role" "slack_notifier" { + count = var.slack_webhook_url != "" ? 1 : 0 + name = "${var.name}-slack-notifier" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Effect = "Allow" + Action = "sts:AssumeRole" + Principal = { Service = "lambda.amazonaws.com" } + }] + }) + + tags = merge(var.tags, { Name = "${var.name}-slack-notifier" }) +} + +resource "aws_iam_role_policy_attachment" "slack_notifier" { + count = var.slack_webhook_url != "" ? 1 : 0 + role = aws_iam_role.slack_notifier[0].name + policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" +} + +resource "aws_lambda_permission" "slack_critical" { + count = var.slack_webhook_url != "" ? 1 : 0 + statement_id = "AllowSNSCritical" + action = "lambda:InvokeFunction" + function_name = aws_lambda_function.slack_notifier[0].function_name + principal = "sns.amazonaws.com" + source_arn = aws_sns_topic.critical.arn +} + +resource "aws_lambda_permission" "slack_warning" { + count = var.slack_webhook_url != "" ? 1 : 0 + statement_id = "AllowSNSWarning" + action = "lambda:InvokeFunction" + function_name = aws_lambda_function.slack_notifier[0].function_name + principal = "sns.amazonaws.com" + source_arn = aws_sns_topic.warning.arn +} + +resource "aws_sns_topic_subscription" "slack_critical" { + count = var.slack_webhook_url != "" ? 1 : 0 + topic_arn = aws_sns_topic.critical.arn + protocol = "lambda" + endpoint = aws_lambda_function.slack_notifier[0].arn +} + +resource "aws_sns_topic_subscription" "slack_warning" { + count = var.slack_webhook_url != "" ? 1 : 0 + topic_arn = aws_sns_topic.warning.arn + protocol = "lambda" + endpoint = aws_lambda_function.slack_notifier[0].arn +} + +################################################################################ +# EventBridge Rules - AWS Health Events +################################################################################ + +resource "aws_cloudwatch_event_rule" "health" { + count = var.enable_aws_health_events ? 1 : 0 + name = "${var.name}-health-events" + description = "Capture AWS Health events" + + event_pattern = jsonencode({ + source = ["aws.health"] + detail-type = ["AWS Health Event"] + }) + + tags = merge(var.tags, { Name = "${var.name}-health" }) +} + +resource "aws_cloudwatch_event_target" "health" { + count = var.enable_aws_health_events ? 1 : 0 + rule = aws_cloudwatch_event_rule.health[0].name + target_id = "SendToSNS" + arn = aws_sns_topic.warning.arn +} + +################################################################################ +# EventBridge Rules - GuardDuty Findings +################################################################################ + +resource "aws_cloudwatch_event_rule" "guardduty" { + count = var.enable_guardduty_events ? 1 : 0 + name = "${var.name}-guardduty-findings" + description = "Capture GuardDuty findings" + + event_pattern = jsonencode({ + source = ["aws.guardduty"] + detail-type = ["GuardDuty Finding"] + detail = { + severity = [{ numeric = [">=", 4] }] # Medium and above + } + }) + + tags = merge(var.tags, { Name = "${var.name}-guardduty" }) +} + +resource "aws_cloudwatch_event_target" "guardduty_critical" { + count = var.enable_guardduty_events ? 1 : 0 + rule = aws_cloudwatch_event_rule.guardduty[0].name + target_id = "SendToSNSCritical" + arn = aws_sns_topic.critical.arn + + input_transformer { + input_paths = { + severity = "$.detail.severity" + title = "$.detail.title" + type = "$.detail.type" + region = "$.region" + } + input_template = <<-EOF + { + "AlarmName": "GuardDuty Finding", + "AlarmDescription": "", + "NewStateValue": "ALARM", + "NewStateReason": "Type: <type>, Severity: <severity>", + "Region": "<region>" + } + EOF + } +} + +################################################################################ +# EventBridge Rules - Security Hub +################################################################################ + +resource "aws_cloudwatch_event_rule" "securityhub" { + count = var.enable_securityhub_events ? 1 : 0 + name = "${var.name}-securityhub-findings" + description = "Capture Security Hub findings" + + event_pattern = jsonencode({ + source = ["aws.securityhub"] + detail-type = ["Security Hub Findings - Imported"] + detail = { + findings = { + Severity = { + Label = ["CRITICAL", "HIGH"] + } + } + } + }) + + tags = merge(var.tags, { Name = "${var.name}-securityhub" }) +} + +resource "aws_cloudwatch_event_target" "securityhub" { + count = var.enable_securityhub_events ? 1 : 0 + rule = aws_cloudwatch_event_rule.securityhub[0].name + target_id = "SendToSNSCritical" + arn = aws_sns_topic.critical.arn +} + +################################################################################ +# Outputs +################################################################################ + +output "critical_topic_arn" { + value = aws_sns_topic.critical.arn + description = "SNS topic for critical alerts" +} + +output "warning_topic_arn" { + value = aws_sns_topic.warning.arn + description = "SNS topic for warning alerts" +} + +output "info_topic_arn" { + value = aws_sns_topic.info.arn + description = "SNS topic for info alerts" +} + +output "topics" { + value = { + critical = aws_sns_topic.critical.arn + warning = aws_sns_topic.warning.arn + info = aws_sns_topic.info.arn + } +} diff --git a/terraform/modules/app-account/README.md b/terraform/modules/app-account/README.md new file mode 100644 index 0000000..342f954 --- /dev/null +++ b/terraform/modules/app-account/README.md @@ -0,0 +1,36 @@ +# app-account + +Terraform module for AWS landing zone pattern. + +Provision new application/workload AWS accounts with account vending pattern. + +## Planned Features + +- [ ] Create account via AWS Organizations +- [ ] Place in appropriate OU +- [ ] Apply account baseline module +- [ ] Configure VPC (shared or dedicated) +- [ ] Create cross-account IAM roles +- [ ] Set up budget alerts +- [ ] Apply standard tags + +## Planned Usage + +```hcl +module "app_account" { + source = "../modules/app-account" + + account_name = "myapp-prod" + account_email = "aws+myapp-prod@company.com" + environment = "prod" + owner = "platform-team" + + vpc_config = { + mode = "shared" # Use shared VPC from network account + } + + budget_limit = 500 + + tags = local.tags +} +``` diff --git a/terraform/modules/app-account/main.tf b/terraform/modules/app-account/main.tf new file mode 100644 index 0000000..ddb7abd --- /dev/null +++ b/terraform/modules/app-account/main.tf @@ -0,0 +1,222 @@ +################################################################################ +# App Account Module +# +# Account vending machine for provisioning new workload accounts: +# - Creates AWS account via Organizations +# - Applies account baseline +# - Sets up cross-account IAM roles +# - Configures budget alerts +################################################################################ + +terraform { + required_version = ">= 1.5.0" + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 5.0" + } + } +} + +data "aws_organizations_organization" "this" {} + +locals { + # Generate account email if not provided + account_email = var.account_email != "" ? var.account_email : "${var.email_prefix}+${var.account_name}@${var.email_domain}" + + # Standard account tags + account_tags = { + AccountName = var.account_name + Environment = var.environment + Owner = var.owner + CostCenter = var.cost_center + OrganizationUnit = var.organizational_unit + ManagedBy = "terraform" + } +} + +################################################################################ +# AWS Account +################################################################################ + +resource "aws_organizations_account" "this" { + name = var.account_name + email = local.account_email + parent_id = var.organizational_unit_id + + # IAM user access to billing (usually disabled) + iam_user_access_to_billing = var.iam_user_access_to_billing ? "ALLOW" : "DENY" + + # Role name for cross-account access from management account + role_name = var.admin_role_name + + # Don't close account on destroy (safety) + close_on_deletion = var.close_on_deletion + + tags = merge(var.tags, local.account_tags) + + lifecycle { + # Prevent accidental deletion + prevent_destroy = false # Set to true in production + + # Email cannot be changed + ignore_changes = [email, role_name] + } +} + +################################################################################ +# Cross-Account IAM Role (in new account) +# Note: This creates a role that can be assumed from the management account +################################################################################ + +# Provider for the new account (assumes role created during account creation) +provider "aws" { + alias = "new_account" + region = var.region + + assume_role { + role_arn = "arn:aws:iam::${aws_organizations_account.this.id}:role/${var.admin_role_name}" + session_name = "terraform-account-setup" + } +} + +# Readonly role for cross-account access +resource "aws_iam_role" "cross_account_readonly" { + provider = aws.new_account + count = var.create_cross_account_roles ? 1 : 0 + + name = "cross-account-readonly" + path = "/cross-account/" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Action = "sts:AssumeRole" + Effect = "Allow" + Principal = { + AWS = var.readonly_trusted_principals + } + }] + }) + + tags = merge(var.tags, { + Name = "cross-account-readonly" + }) + + depends_on = [aws_organizations_account.this] +} + +resource "aws_iam_role_policy_attachment" "cross_account_readonly" { + provider = aws.new_account + count = var.create_cross_account_roles ? 1 : 0 + + role = aws_iam_role.cross_account_readonly[0].name + policy_arn = "arn:aws:iam::aws:policy/ReadOnlyAccess" +} + +# Admin role for cross-account access (requires MFA) +resource "aws_iam_role" "cross_account_admin" { + provider = aws.new_account + count = var.create_cross_account_roles ? 1 : 0 + + name = "cross-account-admin" + path = "/cross-account/" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Action = "sts:AssumeRole" + Effect = "Allow" + Principal = { + AWS = var.admin_trusted_principals + } + Condition = { + Bool = { + "aws:MultiFactorAuthPresent" = "true" + } + } + }] + }) + + max_session_duration = 3600 + + tags = merge(var.tags, { + Name = "cross-account-admin" + }) + + depends_on = [aws_organizations_account.this] +} + +resource "aws_iam_role_policy_attachment" "cross_account_admin" { + provider = aws.new_account + count = var.create_cross_account_roles ? 1 : 0 + + role = aws_iam_role.cross_account_admin[0].name + policy_arn = "arn:aws:iam::aws:policy/AdministratorAccess" +} + +################################################################################ +# Account Baseline (in new account) +################################################################################ + +module "account_baseline" { + source = "../account-baseline" + count = var.apply_baseline ? 1 : 0 + + providers = { + aws = aws.new_account + } + + name = var.account_name + + enable_ebs_encryption = true + enable_s3_block_public = true + enable_password_policy = true + enable_access_analyzer = true + + # Security services typically managed by delegated admin + enable_securityhub = false + enable_guardduty = false + enable_config = false + + tags = merge(var.tags, local.account_tags) + + depends_on = [aws_organizations_account.this] +} + +################################################################################ +# Budget (in new account) +################################################################################ + +resource "aws_budgets_budget" "this" { + provider = aws.new_account + count = var.budget_limit > 0 ? 1 : 0 + + name = "${var.account_name}-monthly-budget" + budget_type = "COST" + limit_amount = tostring(var.budget_limit) + limit_unit = "USD" + time_unit = "MONTHLY" + + notification { + comparison_operator = "GREATER_THAN" + threshold = 80 + threshold_type = "PERCENTAGE" + notification_type = "ACTUAL" + subscriber_email_addresses = [var.owner_email != "" ? var.owner_email : local.account_email] + } + + notification { + comparison_operator = "GREATER_THAN" + threshold = 100 + threshold_type = "PERCENTAGE" + notification_type = "ACTUAL" + subscriber_email_addresses = [var.owner_email != "" ? var.owner_email : local.account_email] + } + + tags = merge(var.tags, { + Name = "${var.account_name}-monthly-budget" + }) + + depends_on = [aws_organizations_account.this] +} diff --git a/terraform/modules/app-account/outputs.tf b/terraform/modules/app-account/outputs.tf new file mode 100644 index 0000000..af4f650 --- /dev/null +++ b/terraform/modules/app-account/outputs.tf @@ -0,0 +1,49 @@ +################################################################################ +# App Account - Outputs +################################################################################ + +output "account_id" { + value = aws_organizations_account.this.id + description = "AWS account ID" +} + +output "account_arn" { + value = aws_organizations_account.this.arn + description = "AWS account ARN" +} + +output "account_name" { + value = aws_organizations_account.this.name + description = "Account name" +} + +output "account_email" { + value = aws_organizations_account.this.email + description = "Account root email" + sensitive = true +} + +output "admin_role_arn" { + value = "arn:aws:iam::${aws_organizations_account.this.id}:role/${var.admin_role_name}" + description = "Admin role ARN for cross-account access" +} + +output "cross_account_readonly_role_arn" { + value = var.create_cross_account_roles ? aws_iam_role.cross_account_readonly[0].arn : null + description = "Cross-account readonly role ARN" +} + +output "cross_account_admin_role_arn" { + value = var.create_cross_account_roles ? aws_iam_role.cross_account_admin[0].arn : null + description = "Cross-account admin role ARN" +} + +output "budget_id" { + value = var.budget_limit > 0 ? aws_budgets_budget.this[0].id : null + description = "Budget ID" +} + +output "account_tags" { + value = local.account_tags + description = "Account tags" +} diff --git a/terraform/modules/app-account/variables.tf b/terraform/modules/app-account/variables.tf new file mode 100644 index 0000000..54ab881 --- /dev/null +++ b/terraform/modules/app-account/variables.tf @@ -0,0 +1,131 @@ +################################################################################ +# App Account - Input Variables +################################################################################ + +# Account Identity +variable "account_name" { + type = string + description = "Name for the new account" +} + +variable "account_email" { + type = string + default = "" + description = "Root email for the account (auto-generated if empty)" +} + +variable "email_prefix" { + type = string + default = "aws" + description = "Email prefix for auto-generated email" +} + +variable "email_domain" { + type = string + default = "example.com" + description = "Email domain for auto-generated email" +} + +# Organization Placement +variable "organizational_unit" { + type = string + default = "Workloads" + description = "OU name (for tagging)" +} + +variable "organizational_unit_id" { + type = string + description = "OU ID to place the account in" +} + +# Account Metadata +variable "environment" { + type = string + description = "Environment type (dev, staging, prod)" + + validation { + condition = contains(["dev", "staging", "prod", "sandbox"], var.environment) + error_message = "Must be dev, staging, prod, or sandbox" + } +} + +variable "cost_center" { + type = string + default = "" + description = "Cost center for billing" +} + +variable "owner" { + type = string + description = "Team/person responsible for this account" +} + +variable "owner_email" { + type = string + default = "" + description = "Owner email for notifications" +} + +variable "region" { + type = string + default = "us-east-1" + description = "Primary region for the account" +} + +# IAM Configuration +variable "admin_role_name" { + type = string + default = "OrganizationAccountAccessRole" + description = "Name of admin role created in new account" +} + +variable "iam_user_access_to_billing" { + type = bool + default = false + description = "Allow IAM users to access billing" +} + +variable "create_cross_account_roles" { + type = bool + default = true + description = "Create cross-account IAM roles" +} + +variable "admin_trusted_principals" { + type = list(string) + default = [] + description = "ARNs allowed to assume admin role" +} + +variable "readonly_trusted_principals" { + type = list(string) + default = [] + description = "ARNs allowed to assume readonly role" +} + +# Baseline Configuration +variable "apply_baseline" { + type = bool + default = true + description = "Apply account baseline configuration" +} + +# Budget +variable "budget_limit" { + type = number + default = 100 + description = "Monthly budget limit in USD (0 = no budget)" +} + +# Safety +variable "close_on_deletion" { + type = bool + default = false + description = "Close account when Terraform resource is deleted" +} + +variable "tags" { + type = map(string) + default = {} + description = "Additional tags" +} diff --git a/terraform/modules/backup-plan/README.md b/terraform/modules/backup-plan/README.md new file mode 100644 index 0000000..ab19985 --- /dev/null +++ b/terraform/modules/backup-plan/README.md @@ -0,0 +1,54 @@ +# backup-plan + +AWS Backup Module + +## Usage + +```hcl +module "backup_plan" { + source = "../modules/backup-plan" + + # Required variables + name = "" + + # Optional: see variables.tf for all options +} +``` + +## Requirements + +| Name | Version | +|------|---------| +| terraform | >= 1.5.0 | +| aws | >= 5.0 | + +## Inputs + +| Name | Description | Type | Required | +|------|-------------|------|----------| +| name | Backup plan name | `string` | yes | +| tenant | Tenant name for resource selection | `string` | no | +| backup_tag_key | Tag key to select resources for backup | `string` | no | +| backup_tag_value | Tag value to select resources for backup | `string` | no | +| daily_retention_days | | `number` | no | +| weekly_retention_days | | `number` | no | +| monthly_retention_days | | `number` | no | +| enable_continuous_backup | Enable continuous backup for point-in-time recovery (RDS, S3... | `bool` | no | +| enable_cross_region_copy | | `bool` | no | +| dr_region | DR region for cross-region backup copy | `string` | no | +| dr_retention_days | | `number` | no | +| kms_key_arn | KMS key ARN for backup encryption (uses AWS managed key if n... | `string` | no | + +## Outputs + +| Name | Description | +|------|-------------| +| vault_arn | | +| vault_name | | +| plan_id | | +| plan_arn | | +| role_arn | | + +## License + +Apache 2.0 - See LICENSE for details. diff --git a/terraform/modules/backup-plan/main.tf b/terraform/modules/backup-plan/main.tf new file mode 100644 index 0000000..b41eec5 --- /dev/null +++ b/terraform/modules/backup-plan/main.tf @@ -0,0 +1,303 @@ +################################################################################ +# AWS Backup Module +# +# Centralized backup management: +# - Daily backups with configurable retention +# - Cross-region copy for DR (optional) +# - Tag-based resource selection +# +# Compliance: Meets HIPAA, SOC 2 backup requirements +# +# Note: Cross-region DR requires passing a provider alias for the DR region: +# +# provider "aws" { +# alias = "dr" +# region = "us-west-2" +# } +# +# module "backup" { +# source = "../modules/backup-plan" +# providers = { +# aws = aws +# aws.dr = aws.dr +# } +# enable_cross_region_copy = true +# dr_region = "us-west-2" +# ... +# } +################################################################################ + +terraform { + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 5.0" + configuration_aliases = [aws.dr] + } + } +} + +variable "name" { + type = string + description = "Backup plan name" +} + +variable "tenant" { + type = string + description = "Tenant name for resource selection" + default = null +} + +variable "backup_tag_key" { + type = string + default = "Backup" + description = "Tag key to select resources for backup" +} + +variable "backup_tag_value" { + type = string + default = "true" + description = "Tag value to select resources for backup" +} + +# Retention settings +variable "daily_retention_days" { + type = number + default = 35 # 5 weeks +} + +variable "weekly_retention_days" { + type = number + default = 90 # ~3 months +} + +variable "monthly_retention_days" { + type = number + default = 365 # 1 year +} + +variable "enable_continuous_backup" { + type = bool + default = false + description = "Enable continuous backup for point-in-time recovery (RDS, S3)" +} + +# Cross-region DR +variable "enable_cross_region_copy" { + type = bool + default = false +} + +variable "dr_region" { + type = string + default = "us-west-2" + description = "DR region for cross-region backup copy" +} + +variable "dr_retention_days" { + type = number + default = 30 +} + +# KMS +variable "kms_key_arn" { + type = string + default = null + description = "KMS key ARN for backup encryption (uses AWS managed key if null)" +} + +################################################################################ +# Backup Vault +################################################################################ + +resource "aws_backup_vault" "main" { + name = var.name + kms_key_arn = var.kms_key_arn + + tags = { Name = var.name } +} + +# Vault lock for compliance (prevents deletion) +resource "aws_backup_vault_lock_configuration" "main" { + backup_vault_name = aws_backup_vault.main.name + min_retention_days = 7 + max_retention_days = 365 + changeable_for_days = 3 # Grace period before lock becomes immutable +} + +################################################################################ +# DR Vault (Cross-Region) +################################################################################ + +resource "aws_backup_vault" "dr" { + count = var.enable_cross_region_copy ? 1 : 0 + provider = aws.dr + + name = "${var.name}-dr" + kms_key_arn = var.kms_key_arn + + tags = { Name = "${var.name}-dr" } +} + +################################################################################ +# IAM Role +################################################################################ + +resource "aws_iam_role" "backup" { + name = "${var.name}-backup" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Effect = "Allow" + Action = "sts:AssumeRole" + Principal = { Service = "backup.amazonaws.com" } + }] + }) + + tags = { Name = "${var.name}-backup" } +} + +resource "aws_iam_role_policy_attachment" "backup" { + role = aws_iam_role.backup.name + policy_arn = "arn:aws:iam::aws:policy/service-role/AWSBackupServiceRolePolicyForBackup" +} + +resource "aws_iam_role_policy_attachment" "restore" { + role = aws_iam_role.backup.name + policy_arn = "arn:aws:iam::aws:policy/service-role/AWSBackupServiceRolePolicyForRestores" +} + +resource "aws_iam_role_policy_attachment" "s3_backup" { + role = aws_iam_role.backup.name + policy_arn = "arn:aws:iam::aws:policy/AWSBackupServiceRolePolicyForS3Backup" +} + +resource "aws_iam_role_policy_attachment" "s3_restore" { + role = aws_iam_role.backup.name + policy_arn = "arn:aws:iam::aws:policy/AWSBackupServiceRolePolicyForS3Restore" +} + +################################################################################ +# Backup Plan +################################################################################ + +resource "aws_backup_plan" "main" { + name = var.name + + # Daily backup at 3 AM UTC + rule { + rule_name = "daily" + target_vault_name = aws_backup_vault.main.name + schedule = "cron(0 3 * * ? *)" + start_window = 60 # 1 hour + completion_window = 180 # 3 hours + + lifecycle { + delete_after = var.daily_retention_days + } + + dynamic "copy_action" { + for_each = var.enable_cross_region_copy ? [1] : [] + content { + destination_vault_arn = aws_backup_vault.dr[0].arn + lifecycle { + delete_after = var.dr_retention_days + } + } + } + } + + # Weekly backup (Sunday 2 AM UTC) + rule { + rule_name = "weekly" + target_vault_name = aws_backup_vault.main.name + schedule = "cron(0 2 ? * SUN *)" + start_window = 60 + completion_window = 180 + + lifecycle { + delete_after = var.weekly_retention_days + } + } + + # Monthly backup (1st of month, 1 AM UTC) + rule { + rule_name = "monthly" + target_vault_name = aws_backup_vault.main.name + schedule = "cron(0 1 1 * ? *)" + start_window = 60 + completion_window = 180 + + lifecycle { + delete_after = var.monthly_retention_days + cold_storage_after = 90 # Move to cold storage after 90 days + } + } + + # Continuous backup (point-in-time recovery) + dynamic "rule" { + for_each = var.enable_continuous_backup ? [1] : [] + content { + rule_name = "continuous" + target_vault_name = aws_backup_vault.main.name + enable_continuous_backup = true + + lifecycle { + delete_after = 35 # Max for continuous backup + } + } + } + + tags = { Name = var.name } +} + +################################################################################ +# Resource Selection +################################################################################ + +resource "aws_backup_selection" "tagged" { + name = "${var.name}-tagged" + plan_id = aws_backup_plan.main.id + iam_role_arn = aws_iam_role.backup.arn + + selection_tag { + type = "STRINGEQUALS" + key = var.backup_tag_key + value = var.backup_tag_value + } + + # If tenant is specified, also match tenant tag + dynamic "selection_tag" { + for_each = var.tenant != null ? [1] : [] + content { + type = "STRINGEQUALS" + key = "Tenant" + value = var.tenant + } + } +} + +################################################################################ +# Outputs +################################################################################ + +output "vault_arn" { + value = aws_backup_vault.main.arn +} + +output "vault_name" { + value = aws_backup_vault.main.name +} + +output "plan_id" { + value = aws_backup_plan.main.id +} + +output "plan_arn" { + value = aws_backup_plan.main.arn +} + +output "role_arn" { + value = aws_iam_role.backup.arn +} diff --git a/terraform/modules/budget-alerts/README.md b/terraform/modules/budget-alerts/README.md new file mode 100644 index 0000000..45a579f --- /dev/null +++ b/terraform/modules/budget-alerts/README.md @@ -0,0 +1,54 @@ +# budget-alerts + +Budget Alerts Module + +## Usage + +```hcl +module "budget_alerts" { + source = "../modules/budget-alerts" + + # Required variables + monthly_budget = "" + + # Optional: see variables.tf for all options +} +``` + +## Requirements + +| Name | Version | +|------|---------| +| terraform | >= 1.5.0 | +| aws | >= 5.0 | + +## Inputs + +| Name | Description | Type | Required | +|------|-------------|------|----------| +| name_prefix | Prefix for budget names | `string` | no | +| monthly_budget | Monthly budget amount in USD | `number` | yes | +| currency | Budget currency | `string` | no | +| alert_emails | Email addresses for budget alerts | `list(string)` | no | +| alert_sns_topic_arn | SNS topic ARN for alerts (creates one if empty) | `string` | no | +| alert_thresholds | Alert thresholds as percentage of budget | `list(number)` | no | +| forecast_alert_threshold | Alert when forecasted spend exceeds this percentage | `number` | no | +| service_budgets | | `map(number)` | no | +| enable_anomaly_detection | Enable AWS Cost Anomaly Detection | `bool` | no | +| anomaly_threshold_percentage | Anomaly alert threshold as percentage above expected | `number` | no | +| anomaly_threshold_absolute | Minimum absolute dollar amount for anomaly alerts | `number` | no | +| tags | | `map(string)` | no | + +## Outputs + +| Name | Description | +|------|-------------| +| monthly_budget_id | Monthly budget ID | +| service_budget_ids | | +| sns_topic_arn | SNS topic ARN for alerts | +| anomaly_monitor_arn | Cost Anomaly Monitor ARN | +| budget_summary | | + +## License + +Apache 2.0 - See LICENSE for details. diff --git a/terraform/modules/budget-alerts/main.tf b/terraform/modules/budget-alerts/main.tf new file mode 100644 index 0000000..4367d44 --- /dev/null +++ b/terraform/modules/budget-alerts/main.tf @@ -0,0 +1,359 @@ +################################################################################ +# Budget Alerts Module +# +# AWS Budgets for cost monitoring: +# - Monthly spend budgets +# - Service-specific budgets +# - Forecasted spend alerts +# - Cost anomaly detection +# - SNS/email notifications +# +# Usage: +# module "budgets" { +# source = "../modules/budget-alerts" +# +# monthly_budget = 1000 +# alert_emails = ["finance@example.com"] +# +# service_budgets = { +# ec2 = 500 +# rds = 200 +# } +# } +################################################################################ + +terraform { + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 5.0" + } + } +} + +variable "name_prefix" { + type = string + default = "account" + description = "Prefix for budget names" +} + +variable "monthly_budget" { + type = number + description = "Monthly budget amount in USD" +} + +variable "currency" { + type = string + default = "USD" + description = "Budget currency" +} + +variable "alert_emails" { + type = list(string) + default = [] + description = "Email addresses for budget alerts" +} + +variable "alert_sns_topic_arn" { + type = string + default = "" + description = "SNS topic ARN for alerts (creates one if empty)" +} + +variable "alert_thresholds" { + type = list(number) + default = [50, 75, 90, 100, 110] + description = "Alert thresholds as percentage of budget" +} + +variable "forecast_alert_threshold" { + type = number + default = 100 + description = "Alert when forecasted spend exceeds this percentage" +} + +variable "service_budgets" { + type = map(number) + default = {} + description = "Per-service budgets (service name -> monthly amount)" +} + +variable "enable_anomaly_detection" { + type = bool + default = true + description = "Enable AWS Cost Anomaly Detection" +} + +variable "anomaly_threshold_percentage" { + type = number + default = 10 + description = "Anomaly alert threshold as percentage above expected" +} + +variable "anomaly_threshold_absolute" { + type = number + default = 100 + description = "Minimum absolute dollar amount for anomaly alerts" +} + +variable "tags" { + type = map(string) + default = {} +} + +################################################################################ +# Data Sources +################################################################################ + +data "aws_caller_identity" "current" {} + +################################################################################ +# SNS Topic for Alerts +################################################################################ + +resource "aws_sns_topic" "budget_alerts" { + count = var.alert_sns_topic_arn == "" ? 1 : 0 + name = "${var.name_prefix}-budget-alerts" + + tags = merge(var.tags, { Name = "${var.name_prefix}-budget-alerts" }) +} + +resource "aws_sns_topic_policy" "budget_alerts" { + count = var.alert_sns_topic_arn == "" ? 1 : 0 + arn = aws_sns_topic.budget_alerts[0].arn + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Sid = "AllowBudgets" + Effect = "Allow" + Principal = { + Service = "budgets.amazonaws.com" + } + Action = "sns:Publish" + Resource = aws_sns_topic.budget_alerts[0].arn + Condition = { + StringEquals = { + "aws:SourceAccount" = data.aws_caller_identity.current.account_id + } + } + }, + { + Sid = "AllowCostAnomaly" + Effect = "Allow" + Principal = { + Service = "costalerts.amazonaws.com" + } + Action = "sns:Publish" + Resource = aws_sns_topic.budget_alerts[0].arn + } + ] + }) +} + +resource "aws_sns_topic_subscription" "email" { + for_each = var.alert_sns_topic_arn == "" ? toset(var.alert_emails) : [] + + topic_arn = aws_sns_topic.budget_alerts[0].arn + protocol = "email" + endpoint = each.value +} + +locals { + sns_topic_arn = var.alert_sns_topic_arn != "" ? var.alert_sns_topic_arn : aws_sns_topic.budget_alerts[0].arn +} + +################################################################################ +# Monthly Account Budget +################################################################################ + +resource "aws_budgets_budget" "monthly" { + name = "${var.name_prefix}-monthly-budget" + budget_type = "COST" + limit_amount = var.monthly_budget + limit_unit = var.currency + time_unit = "MONTHLY" + time_period_start = formatdate("YYYY-MM-01_00:00", timestamp()) + + cost_filter { + name = "LinkedAccount" + values = [data.aws_caller_identity.current.account_id] + } + + # Actual spend alerts + dynamic "notification" { + for_each = var.alert_thresholds + content { + comparison_operator = "GREATER_THAN" + threshold = notification.value + threshold_type = "PERCENTAGE" + notification_type = "ACTUAL" + subscriber_sns_topic_arns = [local.sns_topic_arn] + subscriber_email_addresses = var.alert_sns_topic_arn != "" ? var.alert_emails : [] + } + } + + # Forecasted spend alert + notification { + comparison_operator = "GREATER_THAN" + threshold = var.forecast_alert_threshold + threshold_type = "PERCENTAGE" + notification_type = "FORECASTED" + subscriber_sns_topic_arns = [local.sns_topic_arn] + subscriber_email_addresses = var.alert_sns_topic_arn != "" ? var.alert_emails : [] + } + + tags = merge(var.tags, { Name = "${var.name_prefix}-monthly" }) + + lifecycle { + ignore_changes = [time_period_start] + } +} + +################################################################################ +# Service-Specific Budgets +################################################################################ + +locals { + service_filters = { + ec2 = "Amazon Elastic Compute Cloud - Compute" + rds = "Amazon Relational Database Service" + s3 = "Amazon Simple Storage Service" + lambda = "AWS Lambda" + dynamodb = "Amazon DynamoDB" + cloudfront = "Amazon CloudFront" + elasticache = "Amazon ElastiCache" + eks = "Amazon Elastic Kubernetes Service" + ecs = "Amazon Elastic Container Service" + nat = "EC2 - Other" # NAT Gateway charges + data = "AWS Data Transfer" + } +} + +resource "aws_budgets_budget" "services" { + for_each = var.service_budgets + + name = "${var.name_prefix}-${each.key}-budget" + budget_type = "COST" + limit_amount = each.value + limit_unit = var.currency + time_unit = "MONTHLY" + time_period_start = formatdate("YYYY-MM-01_00:00", timestamp()) + + cost_filter { + name = "Service" + values = [lookup(local.service_filters, each.key, each.key)] + } + + notification { + comparison_operator = "GREATER_THAN" + threshold = 80 + threshold_type = "PERCENTAGE" + notification_type = "ACTUAL" + subscriber_sns_topic_arns = [local.sns_topic_arn] + subscriber_email_addresses = var.alert_sns_topic_arn != "" ? var.alert_emails : [] + } + + notification { + comparison_operator = "GREATER_THAN" + threshold = 100 + threshold_type = "PERCENTAGE" + notification_type = "ACTUAL" + subscriber_sns_topic_arns = [local.sns_topic_arn] + subscriber_email_addresses = var.alert_sns_topic_arn != "" ? var.alert_emails : [] + } + + tags = merge(var.tags, { Name = "${var.name_prefix}-${each.key}" }) + + lifecycle { + ignore_changes = [time_period_start] + } +} + +################################################################################ +# Cost Anomaly Detection +################################################################################ + +resource "aws_ce_anomaly_monitor" "main" { + count = var.enable_anomaly_detection ? 1 : 0 + name = "${var.name_prefix}-anomaly-monitor" + monitor_type = "DIMENSIONAL" + monitor_dimension = "SERVICE" + + tags = merge(var.tags, { Name = "${var.name_prefix}-anomaly-monitor" }) +} + +resource "aws_ce_anomaly_subscription" "main" { + count = var.enable_anomaly_detection ? 1 : 0 + name = "${var.name_prefix}-anomaly-alerts" + frequency = "IMMEDIATE" + + monitor_arn_list = [aws_ce_anomaly_monitor.main[0].arn] + + subscriber { + type = "SNS" + address = local.sns_topic_arn + } + + dynamic "subscriber" { + for_each = var.alert_sns_topic_arn != "" ? var.alert_emails : [] + content { + type = "EMAIL" + address = subscriber.value + } + } + + threshold_expression { + and { + dimension { + key = "ANOMALY_TOTAL_IMPACT_PERCENTAGE" + match_options = ["GREATER_THAN_OR_EQUAL"] + values = [tostring(var.anomaly_threshold_percentage)] + } + } + and { + dimension { + key = "ANOMALY_TOTAL_IMPACT_ABSOLUTE" + match_options = ["GREATER_THAN_OR_EQUAL"] + values = [tostring(var.anomaly_threshold_absolute)] + } + } + } + + tags = merge(var.tags, { Name = "${var.name_prefix}-anomaly-alerts" }) +} + +################################################################################ +# Outputs +################################################################################ + +output "monthly_budget_id" { + value = aws_budgets_budget.monthly.id + description = "Monthly budget ID" +} + +output "service_budget_ids" { + value = { for k, v in aws_budgets_budget.services : k => v.id } + description = "Service budget IDs" +} + +output "sns_topic_arn" { + value = local.sns_topic_arn + description = "SNS topic ARN for alerts" +} + +output "anomaly_monitor_arn" { + value = var.enable_anomaly_detection ? aws_ce_anomaly_monitor.main[0].arn : null + description = "Cost Anomaly Monitor ARN" +} + +output "budget_summary" { + value = { + monthly_limit = "$${var.monthly_budget}/month" + alert_thresholds = [for t in var.alert_thresholds : "${t}%"] + service_limits = { for k, v in var.service_budgets : k => "$${v}/month" } + anomaly_detection = var.enable_anomaly_detection ? "Enabled (>${var.anomaly_threshold_percentage}% and >$${var.anomaly_threshold_absolute})" : "Disabled" + } + description = "Budget configuration summary" +} diff --git a/terraform/modules/cloudtrail/README.md b/terraform/modules/cloudtrail/README.md new file mode 100644 index 0000000..fbc0014 --- /dev/null +++ b/terraform/modules/cloudtrail/README.md @@ -0,0 +1,60 @@ +# cloudtrail + +CloudTrail Module + +## Usage + +```hcl +module "cloudtrail" { + source = "../modules/cloudtrail" + + # Required variables + name = "" + + # Optional: see variables.tf for all options +} +``` + +## Requirements + +| Name | Version | +|------|---------| +| terraform | >= 1.5.0 | +| aws | >= 5.0 | + +## Inputs + +| Name | Description | Type | Required | +|------|-------------|------|----------| +| name | Trail name | `string` | yes | +| s3_bucket_name | S3 bucket for logs (created if empty) | `string` | no | +| is_multi_region | Enable multi-region trail | `bool` | no | +| is_organization_trail | Organization-wide trail (requires org management account) | `bool` | no | +| enable_log_file_validation | Enable log file integrity validation | `bool` | no | +| include_global_service_events | Include global service events (IAM, STS, CloudFront) | `bool` | no | +| enable_cloudwatch_logs | Send logs to CloudWatch Logs | `bool` | no | +| cloudwatch_log_retention_days | CloudWatch log retention in days | `number` | no | +| enable_insights | Enable CloudTrail Insights (additional cost) | `bool` | no | +| insight_selectors | Insight types to enable | `list(string)` | no | +| enable_data_events | Enable data events logging | `bool` | no | +| data_event_s3_buckets | S3 bucket ARNs for data events (empty = all buckets) | `list(string)` | no | +| data_event_lambda_functions | Lambda function ARNs for data events (empty = all functions) | `list(string)` | no | +| data_event_dynamodb_tables | DynamoDB table ARNs for data events | `list(string)` | no | +| kms_key_arn | KMS key ARN for encryption (created if empty) | `string` | no | + +*...and 3 more variables. See `variables.tf` for complete list.* + +## Outputs + +| Name | Description | +|------|-------------| +| trail_arn | CloudTrail ARN | +| trail_name | CloudTrail name | +| s3_bucket | S3 bucket for CloudTrail logs | +| kms_key_arn | KMS key ARN for encryption | +| cloudwatch_log_group | CloudWatch Logs group | +| home_region | Trail home region | + +## License + +Apache 2.0 - See LICENSE for details. diff --git a/terraform/modules/cloudtrail/main.tf b/terraform/modules/cloudtrail/main.tf new file mode 100644 index 0000000..ab2a98b --- /dev/null +++ b/terraform/modules/cloudtrail/main.tf @@ -0,0 +1,506 @@ +################################################################################ +# CloudTrail Module +# +# Audit logging for AWS API activity: +# - Management events (console, CLI, SDK) +# - Data events (S3, Lambda, DynamoDB) +# - Insights events (anomaly detection) +# - Multi-region trail +# - KMS encryption +# - CloudWatch Logs integration +# - S3 bucket with lifecycle +# +# Usage: +# module "cloudtrail" { +# source = "../modules/cloudtrail" +# name = "org-trail" +# +# enable_data_events = true +# data_event_buckets = ["my-bucket"] +# } +################################################################################ + +terraform { + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 5.0" + } + } +} + +variable "name" { + type = string + description = "Trail name" +} + +variable "s3_bucket_name" { + type = string + default = "" + description = "S3 bucket for logs (created if empty)" +} + +variable "is_multi_region" { + type = bool + default = true + description = "Enable multi-region trail" +} + +variable "is_organization_trail" { + type = bool + default = false + description = "Organization-wide trail (requires org management account)" +} + +variable "enable_log_file_validation" { + type = bool + default = true + description = "Enable log file integrity validation" +} + +variable "include_global_service_events" { + type = bool + default = true + description = "Include global service events (IAM, STS, CloudFront)" +} + +variable "enable_cloudwatch_logs" { + type = bool + default = true + description = "Send logs to CloudWatch Logs" +} + +variable "cloudwatch_log_retention_days" { + type = number + default = 90 + description = "CloudWatch log retention in days" +} + +variable "enable_insights" { + type = bool + default = false + description = "Enable CloudTrail Insights (additional cost)" +} + +variable "insight_selectors" { + type = list(string) + default = ["ApiCallRateInsight", "ApiErrorRateInsight"] + description = "Insight types to enable" +} + +variable "enable_data_events" { + type = bool + default = false + description = "Enable data events logging" +} + +variable "data_event_s3_buckets" { + type = list(string) + default = [] + description = "S3 bucket ARNs for data events (empty = all buckets)" +} + +variable "data_event_lambda_functions" { + type = list(string) + default = [] + description = "Lambda function ARNs for data events (empty = all functions)" +} + +variable "data_event_dynamodb_tables" { + type = list(string) + default = [] + description = "DynamoDB table ARNs for data events" +} + +variable "kms_key_arn" { + type = string + default = "" + description = "KMS key ARN for encryption (created if empty)" +} + +variable "s3_log_retention_days" { + type = number + default = 365 + description = "S3 log retention in days" +} + +variable "s3_transition_to_glacier_days" { + type = number + default = 90 + description = "Days before transitioning logs to Glacier" +} + +variable "tags" { + type = map(string) + default = {} +} + +################################################################################ +# Data Sources +################################################################################ + +data "aws_caller_identity" "current" {} +data "aws_region" "current" {} +data "aws_partition" "current" {} + +locals { + bucket_name = var.s3_bucket_name != "" ? var.s3_bucket_name : "${var.name}-cloudtrail-${data.aws_caller_identity.current.account_id}" + create_bucket = var.s3_bucket_name == "" + create_kms = var.kms_key_arn == "" +} + +################################################################################ +# KMS Key +################################################################################ + +resource "aws_kms_key" "cloudtrail" { + count = local.create_kms ? 1 : 0 + + description = "CloudTrail encryption key for ${var.name}" + deletion_window_in_days = 30 + enable_key_rotation = true + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Sid = "Enable IAM policies" + Effect = "Allow" + Principal = { + AWS = "arn:${data.aws_partition.current.partition}:iam::${data.aws_caller_identity.current.account_id}:root" + } + Action = "kms:*" + Resource = "*" + }, + { + Sid = "Allow CloudTrail to encrypt logs" + Effect = "Allow" + Principal = { + Service = "cloudtrail.amazonaws.com" + } + Action = [ + "kms:GenerateDataKey*", + "kms:DescribeKey" + ] + Resource = "*" + Condition = { + StringEquals = { + "aws:SourceArn" = "arn:${data.aws_partition.current.partition}:cloudtrail:${data.aws_region.current.name}:${data.aws_caller_identity.current.account_id}:trail/${var.name}" + } + StringLike = { + "kms:EncryptionContext:aws:cloudtrail:arn" = "arn:${data.aws_partition.current.partition}:cloudtrail:*:${data.aws_caller_identity.current.account_id}:trail/*" + } + } + }, + { + Sid = "Allow CloudTrail to describe key" + Effect = "Allow" + Principal = { + Service = "cloudtrail.amazonaws.com" + } + Action = "kms:DescribeKey" + Resource = "*" + }, + { + Sid = "Allow log decryption" + Effect = "Allow" + Principal = { + AWS = "arn:${data.aws_partition.current.partition}:iam::${data.aws_caller_identity.current.account_id}:root" + } + Action = [ + "kms:Decrypt", + "kms:ReEncryptFrom" + ] + Resource = "*" + Condition = { + StringEquals = { + "kms:CallerAccount" = data.aws_caller_identity.current.account_id + } + StringLike = { + "kms:EncryptionContext:aws:cloudtrail:arn" = "arn:${data.aws_partition.current.partition}:cloudtrail:*:${data.aws_caller_identity.current.account_id}:trail/*" + } + } + } + ] + }) + + tags = merge(var.tags, { Name = "${var.name}-cloudtrail" }) +} + +resource "aws_kms_alias" "cloudtrail" { + count = local.create_kms ? 1 : 0 + name = "alias/${var.name}-cloudtrail" + target_key_id = aws_kms_key.cloudtrail[0].key_id +} + +locals { + kms_key_arn = local.create_kms ? aws_kms_key.cloudtrail[0].arn : var.kms_key_arn +} + +################################################################################ +# S3 Bucket +################################################################################ + +resource "aws_s3_bucket" "cloudtrail" { + count = local.create_bucket ? 1 : 0 + bucket = local.bucket_name + + tags = merge(var.tags, { Name = local.bucket_name }) +} + +resource "aws_s3_bucket_versioning" "cloudtrail" { + count = local.create_bucket ? 1 : 0 + bucket = aws_s3_bucket.cloudtrail[0].id + + versioning_configuration { + status = "Enabled" + } +} + +resource "aws_s3_bucket_server_side_encryption_configuration" "cloudtrail" { + count = local.create_bucket ? 1 : 0 + bucket = aws_s3_bucket.cloudtrail[0].id + + rule { + apply_server_side_encryption_by_default { + sse_algorithm = "aws:kms" + kms_master_key_id = local.kms_key_arn + } + bucket_key_enabled = true + } +} + +resource "aws_s3_bucket_public_access_block" "cloudtrail" { + count = local.create_bucket ? 1 : 0 + bucket = aws_s3_bucket.cloudtrail[0].id + + block_public_acls = true + block_public_policy = true + ignore_public_acls = true + restrict_public_buckets = true +} + +resource "aws_s3_bucket_lifecycle_configuration" "cloudtrail" { + count = local.create_bucket ? 1 : 0 + bucket = aws_s3_bucket.cloudtrail[0].id + + rule { + id = "archive-and-expire" + status = "Enabled" + + transition { + days = var.s3_transition_to_glacier_days + storage_class = "GLACIER" + } + + expiration { + days = var.s3_log_retention_days + } + + noncurrent_version_expiration { + noncurrent_days = 30 + } + } +} + +resource "aws_s3_bucket_policy" "cloudtrail" { + count = local.create_bucket ? 1 : 0 + bucket = aws_s3_bucket.cloudtrail[0].id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Sid = "AWSCloudTrailAclCheck" + Effect = "Allow" + Principal = { + Service = "cloudtrail.amazonaws.com" + } + Action = "s3:GetBucketAcl" + Resource = aws_s3_bucket.cloudtrail[0].arn + Condition = { + StringEquals = { + "aws:SourceArn" = "arn:${data.aws_partition.current.partition}:cloudtrail:${data.aws_region.current.name}:${data.aws_caller_identity.current.account_id}:trail/${var.name}" + } + } + }, + { + Sid = "AWSCloudTrailWrite" + Effect = "Allow" + Principal = { + Service = "cloudtrail.amazonaws.com" + } + Action = "s3:PutObject" + Resource = "${aws_s3_bucket.cloudtrail[0].arn}/*" + Condition = { + StringEquals = { + "s3:x-amz-acl" = "bucket-owner-full-control" + "aws:SourceArn" = "arn:${data.aws_partition.current.partition}:cloudtrail:${data.aws_region.current.name}:${data.aws_caller_identity.current.account_id}:trail/${var.name}" + } + } + }, + { + Sid = "DenyInsecureTransport" + Effect = "Deny" + Principal = "*" + Action = "s3:*" + Resource = [ + aws_s3_bucket.cloudtrail[0].arn, + "${aws_s3_bucket.cloudtrail[0].arn}/*" + ] + Condition = { + Bool = { + "aws:SecureTransport" = "false" + } + } + } + ] + }) +} + +################################################################################ +# CloudWatch Logs +################################################################################ + +resource "aws_cloudwatch_log_group" "cloudtrail" { + count = var.enable_cloudwatch_logs ? 1 : 0 + name = "/aws/cloudtrail/${var.name}" + retention_in_days = var.cloudwatch_log_retention_days + + tags = merge(var.tags, { Name = var.name }) +} + +resource "aws_iam_role" "cloudtrail_cloudwatch" { + count = var.enable_cloudwatch_logs ? 1 : 0 + name = "${var.name}-cloudtrail-cloudwatch" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Effect = "Allow" + Action = "sts:AssumeRole" + Principal = { Service = "cloudtrail.amazonaws.com" } + }] + }) + + tags = merge(var.tags, { Name = "${var.name}-cloudtrail-cloudwatch" }) +} + +resource "aws_iam_role_policy" "cloudtrail_cloudwatch" { + count = var.enable_cloudwatch_logs ? 1 : 0 + name = "cloudwatch-logs" + role = aws_iam_role.cloudtrail_cloudwatch[0].id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Effect = "Allow" + Action = [ + "logs:CreateLogStream", + "logs:PutLogEvents" + ] + Resource = "${aws_cloudwatch_log_group.cloudtrail[0].arn}:*" + }] + }) +} + +################################################################################ +# CloudTrail +################################################################################ + +resource "aws_cloudtrail" "main" { + name = var.name + s3_bucket_name = local.create_bucket ? aws_s3_bucket.cloudtrail[0].id : var.s3_bucket_name + include_global_service_events = var.include_global_service_events + is_multi_region_trail = var.is_multi_region + is_organization_trail = var.is_organization_trail + enable_log_file_validation = var.enable_log_file_validation + kms_key_id = local.kms_key_arn + + cloud_watch_logs_group_arn = var.enable_cloudwatch_logs ? "${aws_cloudwatch_log_group.cloudtrail[0].arn}:*" : null + cloud_watch_logs_role_arn = var.enable_cloudwatch_logs ? aws_iam_role.cloudtrail_cloudwatch[0].arn : null + + # Insights + dynamic "insight_selector" { + for_each = var.enable_insights ? var.insight_selectors : [] + content { + insight_type = insight_selector.value + } + } + + # Data events + dynamic "event_selector" { + for_each = var.enable_data_events ? [1] : [] + content { + read_write_type = "All" + include_management_events = true + + # S3 data events + dynamic "data_resource" { + for_each = length(var.data_event_s3_buckets) > 0 ? [1] : (var.enable_data_events ? [1] : []) + content { + type = "AWS::S3::Object" + values = length(var.data_event_s3_buckets) > 0 ? var.data_event_s3_buckets : ["arn:aws:s3"] + } + } + + # Lambda data events + dynamic "data_resource" { + for_each = length(var.data_event_lambda_functions) > 0 ? [1] : [] + content { + type = "AWS::Lambda::Function" + values = var.data_event_lambda_functions + } + } + + # DynamoDB data events + dynamic "data_resource" { + for_each = length(var.data_event_dynamodb_tables) > 0 ? [1] : [] + content { + type = "AWS::DynamoDB::Table" + values = var.data_event_dynamodb_tables + } + } + } + } + + tags = merge(var.tags, { Name = var.name }) + + depends_on = [ + aws_s3_bucket_policy.cloudtrail, + ] +} + +################################################################################ +# Outputs +################################################################################ + +output "trail_arn" { + value = aws_cloudtrail.main.arn + description = "CloudTrail ARN" +} + +output "trail_name" { + value = aws_cloudtrail.main.name + description = "CloudTrail name" +} + +output "s3_bucket" { + value = local.create_bucket ? aws_s3_bucket.cloudtrail[0].id : var.s3_bucket_name + description = "S3 bucket for CloudTrail logs" +} + +output "kms_key_arn" { + value = local.kms_key_arn + description = "KMS key ARN for encryption" +} + +output "cloudwatch_log_group" { + value = var.enable_cloudwatch_logs ? aws_cloudwatch_log_group.cloudtrail[0].name : null + description = "CloudWatch Logs group" +} + +output "home_region" { + value = aws_cloudtrail.main.home_region + description = "Trail home region" +} diff --git a/terraform/modules/cloudwatch-dashboard/README.md b/terraform/modules/cloudwatch-dashboard/README.md new file mode 100644 index 0000000..d50685d --- /dev/null +++ b/terraform/modules/cloudwatch-dashboard/README.md @@ -0,0 +1,49 @@ +# cloudwatch-dashboard + +CloudWatch Dashboard Module + +## Usage + +```hcl +module "cloudwatch_dashboard" { + source = "../modules/cloudwatch-dashboard" + + # Required variables + name = "" + + # Optional: see variables.tf for all options +} +``` + +## Requirements + +| Name | Version | +|------|---------| +| terraform | >= 1.5.0 | +| aws | >= 5.0 | + +## Inputs + +| Name | Description | Type | Required | +|------|-------------|------|----------| +| name | Dashboard name | `string` | yes | +| ecs_clusters | ECS cluster names to monitor | `list(string)` | no | +| ecs_services | ECS service names to monitor | `list(string)` | no | +| rds_instances | RDS instance identifiers | `list(string)` | no | +| lambda_functions | Lambda function names | `list(string)` | no | +| alb_arns | ALB ARN suffixes (app/name/id) | `list(string)` | no | +| api_gateway_apis | API Gateway API IDs | `list(string)` | no | +| sqs_queues | SQS queue names | `list(string)` | no | +| dynamodb_tables | DynamoDB table names | `list(string)` | no | +| tags | | `map(string)` | no | + +## Outputs + +| Name | Description | +|------|-------------| +| dashboard_name | | +| dashboard_arn | | + +## License + +Apache 2.0 - See LICENSE for details. diff --git a/terraform/modules/cloudwatch-dashboard/main.tf b/terraform/modules/cloudwatch-dashboard/main.tf new file mode 100644 index 0000000..254d6b3 --- /dev/null +++ b/terraform/modules/cloudwatch-dashboard/main.tf @@ -0,0 +1,431 @@ +################################################################################ +# CloudWatch Dashboard Module +# +# Creates CloudWatch dashboards for common AWS services: +# - ECS services +# - RDS databases +# - Lambda functions +# - ALB/NLB +# - API Gateway +# +# Usage: +# module "dashboard" { +# source = "../modules/cloudwatch-dashboard" +# name = "myapp-prod" +# +# ecs_clusters = ["prod-cluster"] +# ecs_services = ["myapp-api"] +# rds_instances = ["myapp-db"] +# lambda_functions = ["myapp-worker"] +# alb_arns = ["arn:aws:elasticloadbalancing:..."] +# } +################################################################################ + +terraform { + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 5.0" + } + } +} + +variable "name" { + type = string + description = "Dashboard name" +} + +variable "ecs_clusters" { + type = list(string) + default = [] + description = "ECS cluster names to monitor" +} + +variable "ecs_services" { + type = list(string) + default = [] + description = "ECS service names to monitor" +} + +variable "rds_instances" { + type = list(string) + default = [] + description = "RDS instance identifiers" +} + +variable "lambda_functions" { + type = list(string) + default = [] + description = "Lambda function names" +} + +variable "alb_arns" { + type = list(string) + default = [] + description = "ALB ARN suffixes (app/name/id)" +} + +variable "api_gateway_apis" { + type = list(string) + default = [] + description = "API Gateway API IDs" +} + +variable "sqs_queues" { + type = list(string) + default = [] + description = "SQS queue names" +} + +variable "dynamodb_tables" { + type = list(string) + default = [] + description = "DynamoDB table names" +} + +variable "tags" { + type = map(string) + default = {} +} + +data "aws_region" "current" {} + +locals { + region = data.aws_region.current.name + + # ECS widgets + ecs_widgets = length(var.ecs_clusters) > 0 ? [ + { + type = "metric" + x = 0 + y = 0 + width = 12 + height = 6 + properties = { + title = "ECS CPU Utilization" + region = local.region + metrics = [ + for i, cluster in var.ecs_clusters : [ + "AWS/ECS", "CPUUtilization", + "ClusterName", cluster, + "ServiceName", try(var.ecs_services[i], cluster) + ] + ] + stat = "Average" + period = 300 + } + }, + { + type = "metric" + x = 12 + y = 0 + width = 12 + height = 6 + properties = { + title = "ECS Memory Utilization" + region = local.region + metrics = [ + for i, cluster in var.ecs_clusters : [ + "AWS/ECS", "MemoryUtilization", + "ClusterName", cluster, + "ServiceName", try(var.ecs_services[i], cluster) + ] + ] + stat = "Average" + period = 300 + } + } + ] : [] + + # RDS widgets + rds_widgets = length(var.rds_instances) > 0 ? [ + { + type = "metric" + x = 0 + y = 6 + width = 8 + height = 6 + properties = { + title = "RDS CPU Utilization" + region = local.region + metrics = [ + for db in var.rds_instances : [ + "AWS/RDS", "CPUUtilization", + "DBInstanceIdentifier", db + ] + ] + stat = "Average" + period = 300 + } + }, + { + type = "metric" + x = 8 + y = 6 + width = 8 + height = 6 + properties = { + title = "RDS Database Connections" + region = local.region + metrics = [ + for db in var.rds_instances : [ + "AWS/RDS", "DatabaseConnections", + "DBInstanceIdentifier", db + ] + ] + stat = "Average" + period = 300 + } + }, + { + type = "metric" + x = 16 + y = 6 + width = 8 + height = 6 + properties = { + title = "RDS Free Storage" + region = local.region + metrics = [ + for db in var.rds_instances : [ + "AWS/RDS", "FreeStorageSpace", + "DBInstanceIdentifier", db + ] + ] + stat = "Average" + period = 300 + } + } + ] : [] + + # Lambda widgets + lambda_widgets = length(var.lambda_functions) > 0 ? [ + { + type = "metric" + x = 0 + y = 12 + width = 8 + height = 6 + properties = { + title = "Lambda Invocations" + region = local.region + metrics = [ + for fn in var.lambda_functions : [ + "AWS/Lambda", "Invocations", + "FunctionName", fn + ] + ] + stat = "Sum" + period = 300 + } + }, + { + type = "metric" + x = 8 + y = 12 + width = 8 + height = 6 + properties = { + title = "Lambda Errors" + region = local.region + metrics = [ + for fn in var.lambda_functions : [ + "AWS/Lambda", "Errors", + "FunctionName", fn + ] + ] + stat = "Sum" + period = 300 + } + }, + { + type = "metric" + x = 16 + y = 12 + width = 8 + height = 6 + properties = { + title = "Lambda Duration" + region = local.region + metrics = [ + for fn in var.lambda_functions : [ + "AWS/Lambda", "Duration", + "FunctionName", fn + ] + ] + stat = "Average" + period = 300 + } + } + ] : [] + + # ALB widgets + alb_widgets = length(var.alb_arns) > 0 ? [ + { + type = "metric" + x = 0 + y = 18 + width = 8 + height = 6 + properties = { + title = "ALB Request Count" + region = local.region + metrics = [ + for alb in var.alb_arns : [ + "AWS/ApplicationELB", "RequestCount", + "LoadBalancer", alb + ] + ] + stat = "Sum" + period = 300 + } + }, + { + type = "metric" + x = 8 + y = 18 + width = 8 + height = 6 + properties = { + title = "ALB 5xx Errors" + region = local.region + metrics = [ + for alb in var.alb_arns : [ + "AWS/ApplicationELB", "HTTPCode_ELB_5XX_Count", + "LoadBalancer", alb + ] + ] + stat = "Sum" + period = 300 + } + }, + { + type = "metric" + x = 16 + y = 18 + width = 8 + height = 6 + properties = { + title = "ALB Response Time" + region = local.region + metrics = [ + for alb in var.alb_arns : [ + "AWS/ApplicationELB", "TargetResponseTime", + "LoadBalancer", alb + ] + ] + stat = "Average" + period = 300 + } + } + ] : [] + + # SQS widgets + sqs_widgets = length(var.sqs_queues) > 0 ? [ + { + type = "metric" + x = 0 + y = 24 + width = 12 + height = 6 + properties = { + title = "SQS Messages Visible" + region = local.region + metrics = [ + for q in var.sqs_queues : [ + "AWS/SQS", "ApproximateNumberOfMessagesVisible", + "QueueName", q + ] + ] + stat = "Average" + period = 300 + } + }, + { + type = "metric" + x = 12 + y = 24 + width = 12 + height = 6 + properties = { + title = "SQS Age of Oldest Message" + region = local.region + metrics = [ + for q in var.sqs_queues : [ + "AWS/SQS", "ApproximateAgeOfOldestMessage", + "QueueName", q + ] + ] + stat = "Maximum" + period = 300 + } + } + ] : [] + + # DynamoDB widgets + dynamodb_widgets = length(var.dynamodb_tables) > 0 ? [ + { + type = "metric" + x = 0 + y = 30 + width = 12 + height = 6 + properties = { + title = "DynamoDB Read Capacity" + region = local.region + metrics = [ + for t in var.dynamodb_tables : [ + "AWS/DynamoDB", "ConsumedReadCapacityUnits", + "TableName", t + ] + ] + stat = "Sum" + period = 300 + } + }, + { + type = "metric" + x = 12 + y = 30 + width = 12 + height = 6 + properties = { + title = "DynamoDB Write Capacity" + region = local.region + metrics = [ + for t in var.dynamodb_tables : [ + "AWS/DynamoDB", "ConsumedWriteCapacityUnits", + "TableName", t + ] + ] + stat = "Sum" + period = 300 + } + } + ] : [] + + all_widgets = concat( + local.ecs_widgets, + local.rds_widgets, + local.lambda_widgets, + local.alb_widgets, + local.sqs_widgets, + local.dynamodb_widgets + ) +} + +resource "aws_cloudwatch_dashboard" "main" { + dashboard_name = var.name + + dashboard_body = jsonencode({ + widgets = local.all_widgets + }) +} + +output "dashboard_name" { + value = aws_cloudwatch_dashboard.main.dashboard_name +} + +output "dashboard_arn" { + value = aws_cloudwatch_dashboard.main.dashboard_arn +} diff --git a/terraform/modules/config-rules/README.md b/terraform/modules/config-rules/README.md new file mode 100644 index 0000000..19251a4 --- /dev/null +++ b/terraform/modules/config-rules/README.md @@ -0,0 +1,51 @@ +# config-rules + +AWS Config Rules Module + +## Usage + +```hcl +module "config_rules" { + source = "../modules/config-rules" + + # Required variables + + # Optional: see variables.tf for all options +} +``` + +## Requirements + +| Name | Version | +|------|---------| +| terraform | >= 1.5.0 | +| aws | >= 5.0 | + +## Inputs + +| Name | Description | Type | Required | +|------|-------------|------|----------| +| enable_aws_config | Enable AWS Config (required for rules) | `bool` | no | +| config_bucket | S3 bucket for Config snapshots (created if empty) | `string` | no | +| config_sns_topic_arn | SNS topic for Config notifications | `string` | no | +| delivery_frequency | Config snapshot delivery frequency | `string` | no | +| enable_cis_benchmark | Enable CIS AWS Foundations Benchmark rules | `bool` | no | +| enable_security_best_practices | Enable AWS Security Best Practices rules | `bool` | no | +| enable_pci_dss | Enable PCI DSS compliance rules | `bool` | no | +| enable_hipaa | Enable HIPAA compliance rules | `bool` | no | +| rules | | `object({` | no | +| auto_remediation | Enable auto-remediation for supported rules | `bool` | no | +| tags | | `map(string)` | no | + +## Outputs + +| Name | Description | +|------|-------------| +| config_recorder_id | Config recorder ID | +| config_bucket | S3 bucket for Config snapshots | +| enabled_rules | | +| compliance_packs | | + +## License + +Apache 2.0 - See LICENSE for details. diff --git a/terraform/modules/config-rules/main.tf b/terraform/modules/config-rules/main.tf new file mode 100644 index 0000000..a6791d8 --- /dev/null +++ b/terraform/modules/config-rules/main.tf @@ -0,0 +1,514 @@ +################################################################################ +# AWS Config Rules Module +# +# Compliance monitoring with managed rules: +# - CIS AWS Foundations Benchmark +# - PCI DSS +# - HIPAA +# - Custom rules +# - Auto-remediation (optional) +# +# Usage: +# module "config_rules" { +# source = "../modules/config-rules" +# +# enable_cis_benchmark = true +# enable_security_best_practices = true +# +# # Or pick individual rules +# rules = { +# s3-bucket-ssl = true +# ec2-imdsv2 = true +# } +# } +################################################################################ + +terraform { + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 5.0" + } + } +} + +variable "enable_aws_config" { + type = bool + default = true + description = "Enable AWS Config (required for rules)" +} + +variable "config_bucket" { + type = string + default = "" + description = "S3 bucket for Config snapshots (created if empty)" +} + +variable "config_sns_topic_arn" { + type = string + default = "" + description = "SNS topic for Config notifications" +} + +variable "delivery_frequency" { + type = string + default = "TwentyFour_Hours" + description = "Config snapshot delivery frequency" +} + +# Compliance Packs +variable "enable_cis_benchmark" { + type = bool + default = false + description = "Enable CIS AWS Foundations Benchmark rules" +} + +variable "enable_security_best_practices" { + type = bool + default = true + description = "Enable AWS Security Best Practices rules" +} + +variable "enable_pci_dss" { + type = bool + default = false + description = "Enable PCI DSS compliance rules" +} + +variable "enable_hipaa" { + type = bool + default = false + description = "Enable HIPAA compliance rules" +} + +# Individual Rules (all optional) +variable "rules" { + type = object({ + # S3 Security + s3_bucket_public_read_prohibited = optional(bool, true) + s3_bucket_public_write_prohibited = optional(bool, true) + s3_bucket_ssl_requests_only = optional(bool, true) + s3_bucket_logging_enabled = optional(bool, false) + s3_bucket_versioning_enabled = optional(bool, false) + s3_default_encryption_kms = optional(bool, false) + + # EC2 Security + ec2_imdsv2_check = optional(bool, true) + ec2_instance_no_public_ip = optional(bool, false) + ec2_ebs_encryption_by_default = optional(bool, true) + ec2_security_group_attached_to_eni = optional(bool, false) + restricted_ssh = optional(bool, true) + restricted_rdp = optional(bool, true) + + # IAM Security + iam_root_access_key_check = optional(bool, true) + iam_user_mfa_enabled = optional(bool, true) + iam_user_no_policies_check = optional(bool, true) + iam_password_policy = optional(bool, true) + access_keys_rotated = optional(bool, true) + access_keys_rotated_days = optional(number, 90) + + # RDS Security + rds_instance_public_access_check = optional(bool, true) + rds_storage_encrypted = optional(bool, true) + rds_multi_az_support = optional(bool, false) + rds_snapshot_encrypted = optional(bool, true) + + # Network Security + vpc_flow_logs_enabled = optional(bool, true) + vpc_default_security_group_closed = optional(bool, true) + + # Encryption + kms_cmk_not_scheduled_for_deletion = optional(bool, true) + encrypted_volumes = optional(bool, true) + + # Logging & Monitoring + cloudtrail_enabled = optional(bool, true) + cloudwatch_alarm_action_check = optional(bool, false) + cw_loggroup_retention_period_check = optional(bool, false) + guardduty_enabled_centralized = optional(bool, false) + + # Lambda + lambda_function_public_access_prohibited = optional(bool, true) + lambda_inside_vpc = optional(bool, false) + }) + default = {} + description = "Individual Config rules to enable" +} + +variable "auto_remediation" { + type = bool + default = false + description = "Enable auto-remediation for supported rules" +} + +variable "tags" { + type = map(string) + default = {} +} + +################################################################################ +# Data Sources +################################################################################ + +data "aws_caller_identity" "current" {} +data "aws_region" "current" {} + +################################################################################ +# S3 Bucket for Config +################################################################################ + +resource "aws_s3_bucket" "config" { + count = var.enable_aws_config && var.config_bucket == "" ? 1 : 0 + bucket = "aws-config-${data.aws_caller_identity.current.account_id}-${data.aws_region.current.name}" + + tags = merge(var.tags, { Name = "aws-config" }) +} + +resource "aws_s3_bucket_versioning" "config" { + count = var.enable_aws_config && var.config_bucket == "" ? 1 : 0 + bucket = aws_s3_bucket.config[0].id + versioning_configuration { status = "Enabled" } +} + +resource "aws_s3_bucket_server_side_encryption_configuration" "config" { + count = var.enable_aws_config && var.config_bucket == "" ? 1 : 0 + bucket = aws_s3_bucket.config[0].id + rule { + apply_server_side_encryption_by_default { sse_algorithm = "AES256" } + } +} + +resource "aws_s3_bucket_public_access_block" "config" { + count = var.enable_aws_config && var.config_bucket == "" ? 1 : 0 + bucket = aws_s3_bucket.config[0].id + block_public_acls = true + block_public_policy = true + ignore_public_acls = true + restrict_public_buckets = true +} + +locals { + config_bucket = var.config_bucket != "" ? var.config_bucket : (var.enable_aws_config ? aws_s3_bucket.config[0].id : "") +} + +################################################################################ +# IAM Role for Config +################################################################################ + +resource "aws_iam_role" "config" { + count = var.enable_aws_config ? 1 : 0 + name = "AWSConfigRole" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Effect = "Allow" + Action = "sts:AssumeRole" + Principal = { Service = "config.amazonaws.com" } + }] + }) + + tags = merge(var.tags, { Name = "AWSConfigRole" }) +} + +resource "aws_iam_role_policy_attachment" "config" { + count = var.enable_aws_config ? 1 : 0 + role = aws_iam_role.config[0].name + policy_arn = "arn:aws:iam::aws:policy/service-role/AWS_ConfigRole" +} + +resource "aws_iam_role_policy" "config_s3" { + count = var.enable_aws_config ? 1 : 0 + name = "s3-delivery" + role = aws_iam_role.config[0].id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Effect = "Allow" + Action = ["s3:PutObject", "s3:PutObjectAcl"] + Resource = "arn:aws:s3:::${local.config_bucket}/*" + Condition = { + StringLike = { "s3:x-amz-acl" = "bucket-owner-full-control" } + } + }, + { + Effect = "Allow" + Action = "s3:GetBucketAcl" + Resource = "arn:aws:s3:::${local.config_bucket}" + } + ] + }) +} + +################################################################################ +# AWS Config Recorder +################################################################################ + +resource "aws_config_configuration_recorder" "main" { + count = var.enable_aws_config ? 1 : 0 + name = "default" + role_arn = aws_iam_role.config[0].arn + + recording_group { + all_supported = true + include_global_resource_types = true + } +} + +resource "aws_config_delivery_channel" "main" { + count = var.enable_aws_config ? 1 : 0 + name = "default" + s3_bucket_name = local.config_bucket + sns_topic_arn = var.config_sns_topic_arn != "" ? var.config_sns_topic_arn : null + + snapshot_delivery_properties { + delivery_frequency = var.delivery_frequency + } + + depends_on = [aws_config_configuration_recorder.main] +} + +resource "aws_config_configuration_recorder_status" "main" { + count = var.enable_aws_config ? 1 : 0 + name = aws_config_configuration_recorder.main[0].name + is_enabled = true + + depends_on = [aws_config_delivery_channel.main] +} + +################################################################################ +# Security Best Practices Rules +################################################################################ + +# S3 Rules +resource "aws_config_config_rule" "s3_bucket_public_read_prohibited" { + count = var.enable_aws_config && (var.rules.s3_bucket_public_read_prohibited || var.enable_security_best_practices) ? 1 : 0 + name = "s3-bucket-public-read-prohibited" + source { + owner = "AWS" + source_identifier = "S3_BUCKET_PUBLIC_READ_PROHIBITED" + } + depends_on = [aws_config_configuration_recorder.main] + tags = var.tags +} + +resource "aws_config_config_rule" "s3_bucket_public_write_prohibited" { + count = var.enable_aws_config && (var.rules.s3_bucket_public_write_prohibited || var.enable_security_best_practices) ? 1 : 0 + name = "s3-bucket-public-write-prohibited" + source { + owner = "AWS" + source_identifier = "S3_BUCKET_PUBLIC_WRITE_PROHIBITED" + } + depends_on = [aws_config_configuration_recorder.main] + tags = var.tags +} + +resource "aws_config_config_rule" "s3_bucket_ssl_requests_only" { + count = var.enable_aws_config && (var.rules.s3_bucket_ssl_requests_only || var.enable_security_best_practices) ? 1 : 0 + name = "s3-bucket-ssl-requests-only" + source { + owner = "AWS" + source_identifier = "S3_BUCKET_SSL_REQUESTS_ONLY" + } + depends_on = [aws_config_configuration_recorder.main] + tags = var.tags +} + +# EC2 Rules +resource "aws_config_config_rule" "ec2_imdsv2_check" { + count = var.enable_aws_config && (var.rules.ec2_imdsv2_check || var.enable_security_best_practices) ? 1 : 0 + name = "ec2-imdsv2-check" + source { + owner = "AWS" + source_identifier = "EC2_IMDSV2_CHECK" + } + depends_on = [aws_config_configuration_recorder.main] + tags = var.tags +} + +resource "aws_config_config_rule" "ebs_encryption_by_default" { + count = var.enable_aws_config && (var.rules.ec2_ebs_encryption_by_default || var.enable_security_best_practices) ? 1 : 0 + name = "ec2-ebs-encryption-by-default-check" + source { + owner = "AWS" + source_identifier = "EC2_EBS_ENCRYPTION_BY_DEFAULT" + } + depends_on = [aws_config_configuration_recorder.main] + tags = var.tags +} + +resource "aws_config_config_rule" "restricted_ssh" { + count = var.enable_aws_config && (var.rules.restricted_ssh || var.enable_security_best_practices) ? 1 : 0 + name = "restricted-ssh" + source { + owner = "AWS" + source_identifier = "INCOMING_SSH_DISABLED" + } + depends_on = [aws_config_configuration_recorder.main] + tags = var.tags +} + +# IAM Rules +resource "aws_config_config_rule" "iam_root_access_key_check" { + count = var.enable_aws_config && (var.rules.iam_root_access_key_check || var.enable_security_best_practices) ? 1 : 0 + name = "iam-root-access-key-check" + source { + owner = "AWS" + source_identifier = "IAM_ROOT_ACCESS_KEY_CHECK" + } + depends_on = [aws_config_configuration_recorder.main] + tags = var.tags +} + +resource "aws_config_config_rule" "iam_user_mfa_enabled" { + count = var.enable_aws_config && (var.rules.iam_user_mfa_enabled || var.enable_security_best_practices) ? 1 : 0 + name = "iam-user-mfa-enabled" + source { + owner = "AWS" + source_identifier = "IAM_USER_MFA_ENABLED" + } + depends_on = [aws_config_configuration_recorder.main] + tags = var.tags +} + +resource "aws_config_config_rule" "access_keys_rotated" { + count = var.enable_aws_config && (var.rules.access_keys_rotated || var.enable_security_best_practices) ? 1 : 0 + name = "access-keys-rotated" + source { + owner = "AWS" + source_identifier = "ACCESS_KEYS_ROTATED" + } + input_parameters = jsonencode({ + maxAccessKeyAge = var.rules.access_keys_rotated_days + }) + depends_on = [aws_config_configuration_recorder.main] + tags = var.tags +} + +# RDS Rules +resource "aws_config_config_rule" "rds_instance_public_access_check" { + count = var.enable_aws_config && (var.rules.rds_instance_public_access_check || var.enable_security_best_practices) ? 1 : 0 + name = "rds-instance-public-access-check" + source { + owner = "AWS" + source_identifier = "RDS_INSTANCE_PUBLIC_ACCESS_CHECK" + } + depends_on = [aws_config_configuration_recorder.main] + tags = var.tags +} + +resource "aws_config_config_rule" "rds_storage_encrypted" { + count = var.enable_aws_config && (var.rules.rds_storage_encrypted || var.enable_security_best_practices) ? 1 : 0 + name = "rds-storage-encrypted" + source { + owner = "AWS" + source_identifier = "RDS_STORAGE_ENCRYPTED" + } + depends_on = [aws_config_configuration_recorder.main] + tags = var.tags +} + +# Network Rules +resource "aws_config_config_rule" "vpc_flow_logs_enabled" { + count = var.enable_aws_config && (var.rules.vpc_flow_logs_enabled || var.enable_security_best_practices) ? 1 : 0 + name = "vpc-flow-logs-enabled" + source { + owner = "AWS" + source_identifier = "VPC_FLOW_LOGS_ENABLED" + } + depends_on = [aws_config_configuration_recorder.main] + tags = var.tags +} + +resource "aws_config_config_rule" "vpc_default_security_group_closed" { + count = var.enable_aws_config && (var.rules.vpc_default_security_group_closed || var.enable_security_best_practices) ? 1 : 0 + name = "vpc-default-security-group-closed" + source { + owner = "AWS" + source_identifier = "VPC_DEFAULT_SECURITY_GROUP_CLOSED" + } + depends_on = [aws_config_configuration_recorder.main] + tags = var.tags +} + +# CloudTrail Rule +resource "aws_config_config_rule" "cloudtrail_enabled" { + count = var.enable_aws_config && (var.rules.cloudtrail_enabled || var.enable_security_best_practices) ? 1 : 0 + name = "cloudtrail-enabled" + source { + owner = "AWS" + source_identifier = "CLOUD_TRAIL_ENABLED" + } + depends_on = [aws_config_configuration_recorder.main] + tags = var.tags +} + +# Encryption Rules +resource "aws_config_config_rule" "encrypted_volumes" { + count = var.enable_aws_config && (var.rules.encrypted_volumes || var.enable_security_best_practices) ? 1 : 0 + name = "encrypted-volumes" + source { + owner = "AWS" + source_identifier = "ENCRYPTED_VOLUMES" + } + depends_on = [aws_config_configuration_recorder.main] + tags = var.tags +} + +# Lambda Rules +resource "aws_config_config_rule" "lambda_function_public_access_prohibited" { + count = var.enable_aws_config && (var.rules.lambda_function_public_access_prohibited || var.enable_security_best_practices) ? 1 : 0 + name = "lambda-function-public-access-prohibited" + source { + owner = "AWS" + source_identifier = "LAMBDA_FUNCTION_PUBLIC_ACCESS_PROHIBITED" + } + depends_on = [aws_config_configuration_recorder.main] + tags = var.tags +} + +################################################################################ +# Outputs +################################################################################ + +output "config_recorder_id" { + value = var.enable_aws_config ? aws_config_configuration_recorder.main[0].id : null + description = "Config recorder ID" +} + +output "config_bucket" { + value = local.config_bucket + description = "S3 bucket for Config snapshots" +} + +output "enabled_rules" { + value = var.enable_aws_config ? { + s3_public_read = var.rules.s3_bucket_public_read_prohibited || var.enable_security_best_practices + s3_public_write = var.rules.s3_bucket_public_write_prohibited || var.enable_security_best_practices + s3_ssl_only = var.rules.s3_bucket_ssl_requests_only || var.enable_security_best_practices + ec2_imdsv2 = var.rules.ec2_imdsv2_check || var.enable_security_best_practices + ebs_encryption = var.rules.ec2_ebs_encryption_by_default || var.enable_security_best_practices + restricted_ssh = var.rules.restricted_ssh || var.enable_security_best_practices + iam_root_key = var.rules.iam_root_access_key_check || var.enable_security_best_practices + iam_mfa = var.rules.iam_user_mfa_enabled || var.enable_security_best_practices + access_key_rotation = var.rules.access_keys_rotated || var.enable_security_best_practices + rds_public = var.rules.rds_instance_public_access_check || var.enable_security_best_practices + rds_encrypted = var.rules.rds_storage_encrypted || var.enable_security_best_practices + vpc_flow_logs = var.rules.vpc_flow_logs_enabled || var.enable_security_best_practices + cloudtrail = var.rules.cloudtrail_enabled || var.enable_security_best_practices + } : null + description = "List of enabled Config rules" +} + +output "compliance_packs" { + value = { + cis_benchmark = var.enable_cis_benchmark + security_best = var.enable_security_best_practices + pci_dss = var.enable_pci_dss + hipaa = var.enable_hipaa + } + description = "Enabled compliance packs" +} diff --git a/terraform/modules/github-oidc/README.md b/terraform/modules/github-oidc/README.md new file mode 100644 index 0000000..5a81468 --- /dev/null +++ b/terraform/modules/github-oidc/README.md @@ -0,0 +1,229 @@ +# GitHub OIDC Module + +Secure CI/CD access from GitHub Actions to AWS without long-lived credentials. + +## Features + +- 🔐 **OIDC Provider** - Automatic setup of GitHub OIDC trust +- 🎯 **Fine-grained access** - Restrict by repo, branch, tag, environment +- 📦 **Pre-built templates** - Common patterns for Terraform, ECR, S3, Lambda +- 🔧 **Custom roles** - Full flexibility for any use case +- 📝 **Policy generation** - Build policies from simple statements + +## Quick Start + +```hcl +module "github_oidc" { + source = "../modules/github-oidc" + + github_org = "myorg" + + # Custom role + roles = { + deploy = { + repos = ["myrepo"] + branches = ["main"] + policy_arns = ["arn:aws:iam::aws:policy/PowerUserAccess"] + } + } +} +``` + +## Pre-built Templates + +### Terraform Deployments + +```hcl +module "github_oidc" { + source = "../modules/github-oidc" + + github_org = "myorg" + + terraform_deploy_role = { + enabled = true + repos = ["infrastructure"] + branches = ["main"] + environments = ["production"] + state_bucket = "myorg-tf-state" + dynamodb_table = "terraform-locks" + } +} +``` + +### ECR Push + +```hcl +module "github_oidc" { + source = "../modules/github-oidc" + + github_org = "myorg" + + ecr_push_role = { + enabled = true + repos = ["backend", "frontend"] + branches = ["main", "develop"] + ecr_repos = ["backend", "frontend"] + allow_create = false + } +} +``` + +### S3 Static Site Deploy + +```hcl +module "github_oidc" { + source = "../modules/github-oidc" + + github_org = "myorg" + + s3_deploy_role = { + enabled = true + repos = ["website"] + branches = ["main"] + bucket_arns = ["arn:aws:s3:::mysite.com"] + cloudfront_arns = ["arn:aws:cloudfront::123456789012:distribution/EXAMPLE"] + } +} +``` + +### Lambda Deploy + +```hcl +module "github_oidc" { + source = "../modules/github-oidc" + + github_org = "myorg" + + lambda_deploy_role = { + enabled = true + repos = ["serverless-api"] + branches = ["main"] + function_arns = ["arn:aws:lambda:us-east-1:123456789012:function:my-api"] + } +} +``` + +## Advanced Usage + +### Multiple Custom Roles + +```hcl +module "github_oidc" { + source = "../modules/github-oidc" + + github_org = "myorg" + + roles = { + # Read-only for PRs + preview = { + repos = ["webapp"] + pull_request = true + policy_arns = ["arn:aws:iam::aws:policy/ReadOnlyAccess"] + } + + # Full deploy for main + deploy = { + repos = ["webapp"] + branches = ["main"] + policy_arns = ["arn:aws:iam::aws:policy/PowerUserAccess"] + } + + # Tag-based releases + release = { + repos = ["webapp"] + tags = ["v*"] + policy_statements = [{ + actions = ["s3:PutObject", "cloudfront:CreateInvalidation"] + resources = ["*"] + }] + } + } +} +``` + +### Reusable Workflow Restriction + +```hcl +roles = { + deploy = { + repos = ["*"] # Any repo in org + workflow_ref = "myorg/workflows/.github/workflows/deploy.yml@main" + policy_arns = ["arn:aws:iam::aws:policy/PowerUserAccess"] + } +} +``` + +### Custom Trust Conditions + +```hcl +roles = { + restricted = { + repos = ["myrepo"] + branches = ["main"] + extra_conditions = { + StringEquals = { + "token.actions.githubusercontent.com:actor" = ["trusted-user"] + } + } + policy_arns = ["arn:aws:iam::aws:policy/AdministratorAccess"] + } +} +``` + +## GitHub Actions Workflow + +```yaml +name: Deploy +on: + push: + branches: [main] + +jobs: + deploy: + runs-on: ubuntu-latest + permissions: + id-token: write # Required for OIDC + contents: read + + steps: + - uses: actions/checkout@v4 + + - uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: arn:aws:iam::123456789012:role/github-deploy + aws-region: us-east-1 + + - run: aws sts get-caller-identity +``` + +## Inputs + +| Name | Description | Type | Default | +|------|-------------|------|---------| +| `create_provider` | Create OIDC provider | `bool` | `true` | +| `provider_arn` | Existing provider ARN | `string` | `""` | +| `github_org` | GitHub organization | `string` | `""` | +| `name_prefix` | Role name prefix | `string` | `"github"` | +| `roles` | Custom role configs | `map(object)` | `{}` | +| `terraform_deploy_role` | Terraform template | `object` | `{}` | +| `ecr_push_role` | ECR template | `object` | `{}` | +| `s3_deploy_role` | S3 template | `object` | `{}` | +| `lambda_deploy_role` | Lambda template | `object` | `{}` | + +## Outputs + +| Name | Description | +|------|-------------| +| `provider_arn` | OIDC provider ARN | +| `role_arns` | Map of custom role ARNs | +| `all_role_arns` | All role ARNs (custom + templates) | +| `terraform_role_arn` | Terraform role ARN | +| `ecr_role_arn` | ECR role ARN | +| `workflow_examples` | Example workflow snippets | + +## Security Considerations + +1. **Principle of least privilege** - Use specific repos/branches, not wildcards +2. **Environment protection** - Use GitHub environments for production +3. **Permissions boundary** - Consider attaching a boundary for defense-in-depth +4. **Audit** - CloudTrail logs all AssumeRoleWithWebIdentity calls diff --git a/terraform/modules/github-oidc/examples/basic/main.tf b/terraform/modules/github-oidc/examples/basic/main.tf new file mode 100644 index 0000000..70a0745 --- /dev/null +++ b/terraform/modules/github-oidc/examples/basic/main.tf @@ -0,0 +1,54 @@ +################################################################################ +# GitHub OIDC - Basic Example +# +# Single role with branch restriction +################################################################################ + +terraform { + required_version = ">= 1.5.0" + + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 5.0" + } + } +} + +provider "aws" { + region = "us-east-1" +} + +module "github_oidc" { + source = "../../" + + github_org = "example-org" + name_prefix = "github" + + roles = { + deploy = { + repos = ["my-app"] + branches = ["main"] + policy_statements = [ + { + sid = "S3Access" + actions = ["s3:GetObject", "s3:PutObject"] + resources = ["arn:aws:s3:::my-bucket/*"] + } + ] + } + } + + tags = { + Environment = "production" + Project = "my-app" + } +} + +output "role_arn" { + value = module.github_oidc.role_arns["deploy"] +} + +output "provider_arn" { + value = module.github_oidc.provider_arn +} diff --git a/terraform/modules/github-oidc/examples/multi-role/main.tf b/terraform/modules/github-oidc/examples/multi-role/main.tf new file mode 100644 index 0000000..5124dd2 --- /dev/null +++ b/terraform/modules/github-oidc/examples/multi-role/main.tf @@ -0,0 +1,126 @@ +################################################################################ +# GitHub OIDC - Multi-Role Example +# +# Multiple roles with different permission levels +################################################################################ + +terraform { + required_version = ">= 1.5.0" + + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 5.0" + } + } +} + +provider "aws" { + region = "us-east-1" +} + +# Permissions boundary for defense-in-depth +resource "aws_iam_policy" "github_boundary" { + name = "GitHubActionsBoundary" + description = "Permissions boundary for GitHub Actions roles" + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Sid = "AllowedServices" + Effect = "Allow" + Action = ["s3:*", "ecr:*", "lambda:*", "logs:*", "cloudwatch:*"] + Resource = "*" + }, + { + Sid = "DenyDangerous" + Effect = "Deny" + Action = [ + "iam:CreateUser", + "iam:CreateAccessKey", + "organizations:*", + "account:*" + ] + Resource = "*" + } + ] + }) +} + +module "github_oidc" { + source = "../../" + + github_org = "example-org" + name_prefix = "github" + permissions_boundary = aws_iam_policy.github_boundary.arn + + # Security settings + max_session_hours_limit = 2 + deny_wildcard_repos = true + + roles = { + # Read-only for PR validation + validate = { + repos = ["infrastructure", "application"] + pull_request = true + policy_arns = ["arn:aws:iam::aws:policy/ReadOnlyAccess"] + max_session_hours = 1 + } + + # Deploy from main branch only + deploy = { + repos = ["infrastructure"] + branches = ["main"] + environments = ["production"] + policy_statements = [ + { + sid = "DeployAccess" + actions = ["s3:*", "cloudfront:*", "lambda:*"] + resources = ["*"] + } + ] + max_session_hours = 2 + } + + # Release automation from tags + release = { + repos = ["application"] + tags = ["v*", "release-*"] + branches = [] # Only tags + policy_statements = [ + { + sid = "ECRPush" + actions = ["ecr:*"] + resources = ["arn:aws:ecr:*:*:repository/application"] + } + ] + } + + # Reusable workflow restriction + shared = { + repos = ["*"] # Any repo + workflow_ref = "example-org/shared-workflows/.github/workflows/deploy.yml@main" + policy_statements = [ + { + sid = "SharedDeploy" + actions = ["s3:PutObject"] + resources = ["arn:aws:s3:::artifacts-bucket/*"] + } + ] + } + } + + tags = { + Environment = "production" + CostCenter = "platform" + } +} + +output "all_roles" { + value = module.github_oidc.all_role_arns +} + +output "security_status" { + value = module.github_oidc.security_recommendations +} diff --git a/terraform/modules/github-oidc/examples/templates/main.tf b/terraform/modules/github-oidc/examples/templates/main.tf new file mode 100644 index 0000000..f596de1 --- /dev/null +++ b/terraform/modules/github-oidc/examples/templates/main.tf @@ -0,0 +1,159 @@ +################################################################################ +# GitHub OIDC - Pre-built Templates Example +# +# Using pre-built role templates for common patterns +################################################################################ + +terraform { + required_version = ">= 1.5.0" + + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 5.0" + } + } +} + +provider "aws" { + region = "us-east-1" +} + +data "aws_caller_identity" "current" {} + +# Prerequisites - S3 bucket for Terraform state +resource "aws_s3_bucket" "terraform_state" { + bucket_prefix = "terraform-state-" + force_destroy = true # For example only - remove in production + + tags = { + Purpose = "terraform-state" + } +} + +resource "aws_s3_bucket_versioning" "terraform_state" { + bucket = aws_s3_bucket.terraform_state.id + versioning_configuration { + status = "Enabled" + } +} + +resource "aws_dynamodb_table" "terraform_locks" { + name = "terraform-locks" + billing_mode = "PAY_PER_REQUEST" + hash_key = "LockID" + + attribute { + name = "LockID" + type = "S" + } + + tags = { + Purpose = "terraform-locks" + } +} + +# ECR repository for container builds +resource "aws_ecr_repository" "app" { + name = "my-application" + image_tag_mutability = "IMMUTABLE" + + image_scanning_configuration { + scan_on_push = true + } + + tags = { + Purpose = "container-registry" + } +} + +# GitHub OIDC with all templates enabled +module "github_oidc" { + source = "../../" + + github_org = "example-org" + name_prefix = "github" + + # Terraform deployment role + terraform_deploy_role = { + enabled = true + repos = ["infrastructure"] + branches = ["main"] + environments = ["production"] + state_bucket = aws_s3_bucket.terraform_state.id + state_bucket_key_prefix = "live/*" + dynamodb_table = aws_dynamodb_table.terraform_locks.name + allowed_services = ["ec2", "s3", "iam", "lambda", "rds", "vpc"] + denied_actions = [ + "iam:CreateUser", + "iam:CreateAccessKey", + "organizations:*" + ] + } + + # ECR push role for container builds + ecr_push_role = { + enabled = true + repos = ["my-application", "backend-api"] + branches = ["main", "develop"] + ecr_repos = [aws_ecr_repository.app.name] + allow_create = false + allow_delete = false + } + + # S3 deploy role for static sites + s3_deploy_role = { + enabled = true + repos = ["frontend"] + branches = ["main"] + bucket_arns = ["arn:aws:s3:::www.example.com"] + allowed_prefixes = ["*"] + cloudfront_arns = [] # Add CloudFront distribution ARN if needed + } + + # Lambda deploy role for serverless + lambda_deploy_role = { + enabled = true + repos = ["serverless-api"] + branches = ["main"] + function_arns = ["arn:aws:lambda:us-east-1:${data.aws_caller_identity.current.account_id}:function:api-*"] + allow_create = false + allow_logs = true + } + + tags = { + Environment = "production" + ManagedBy = "terraform" + } +} + +# Outputs +output "terraform_role_arn" { + description = "Role ARN for Terraform deployments" + value = module.github_oidc.terraform_role_arn +} + +output "ecr_role_arn" { + description = "Role ARN for ECR push operations" + value = module.github_oidc.ecr_role_arn +} + +output "s3_deploy_role_arn" { + description = "Role ARN for S3 static site deployments" + value = module.github_oidc.s3_deploy_role_arn +} + +output "lambda_deploy_role_arn" { + description = "Role ARN for Lambda deployments" + value = module.github_oidc.lambda_deploy_role_arn +} + +output "all_roles" { + description = "All created role ARNs" + value = module.github_oidc.all_role_arns +} + +output "workflow_examples" { + description = "Example workflow snippets" + value = module.github_oidc.workflow_examples +} diff --git a/terraform/modules/github-oidc/main.tf b/terraform/modules/github-oidc/main.tf new file mode 100644 index 0000000..c9c7b46 --- /dev/null +++ b/terraform/modules/github-oidc/main.tf @@ -0,0 +1,673 @@ +################################################################################ +# GitHub OIDC Module +# +# AWS/Terraform/Security Best Practices: +# - Least privilege IAM policies +# - Input validation +# - Explicit denies for dangerous actions +# - Session duration limits +# - CloudTrail monitoring integration +# - Permissions boundary support +# - No wildcard repos by default +# +# Security scanning: tfsec, checkov, tflint-aws +################################################################################ + +data "aws_caller_identity" "current" {} +data "aws_region" "current" {} +data "aws_partition" "current" {} + +################################################################################ +# Local Variables & Validation +################################################################################ + +locals { + account_id = data.aws_caller_identity.current.account_id + region = data.aws_region.current.id + partition = data.aws_partition.current.partition + + # Validate permissions boundary requirement + boundary_check = var.require_permissions_boundary && var.permissions_boundary == null ? tobool("Permissions boundary is required but not set") : true + + # Normalize repo names with org prefix + normalize_repo = { for k, v in var.roles : k => merge(v, { + repos = [for repo in v.repos : + !contains(split("/", repo), "/") && var.github_org != "" + ? "${var.github_org}/${repo}" + : repo + ] + # Cap session duration at limit + max_session_hours = min(v.max_session_hours, var.max_session_hours_limit) + })} + + # Validate no wildcard repos unless workflow_ref is set + wildcard_check = var.deny_wildcard_repos ? alltrue([ + for k, v in var.roles : !contains(v.repos, "*") || v.workflow_ref != "" + ]) : true + + _ = local.wildcard_check ? true : tobool("Wildcard repos (*) require workflow_ref restriction or deny_wildcard_repos=false") + + # Common tags + common_tags = merge(var.tags, { + ManagedBy = "terraform" + Module = "github-oidc" + }) +} + +################################################################################ +# OIDC Provider +################################################################################ + +data "tls_certificate" "github" { + count = var.create_provider ? 1 : 0 + url = "https://token.actions.githubusercontent.com" +} + +resource "aws_iam_openid_connect_provider" "github" { + count = var.create_provider ? 1 : 0 + + url = "https://token.actions.githubusercontent.com" + client_id_list = ["sts.amazonaws.com"] + thumbprint_list = [data.tls_certificate.github[0].certificates[0].sha1_fingerprint] + + tags = merge(local.common_tags, { + Name = "github-actions-oidc" + Description = "GitHub Actions OIDC Identity Provider" + }) +} + +locals { + provider_arn = var.create_provider ? aws_iam_openid_connect_provider.github[0].arn : var.provider_arn +} + +################################################################################ +# Custom Roles +################################################################################ + +resource "aws_iam_role" "github" { + for_each = local.normalize_repo + + name = "${var.name_prefix}-${each.key}" + path = var.path + description = "GitHub Actions: ${join(", ", each.value.repos)}" + max_session_duration = each.value.max_session_hours * 3600 + permissions_boundary = var.permissions_boundary + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Sid = "GitHubActionsOIDC" + Effect = "Allow" + Action = "sts:AssumeRoleWithWebIdentity" + Principal = { + Federated = local.provider_arn + } + Condition = merge( + { + StringEquals = { + "token.actions.githubusercontent.com:aud" = "sts.amazonaws.com" + } + StringLike = { + "token.actions.githubusercontent.com:sub" = distinct(compact(concat( + # Branch-based subjects + flatten([for repo in each.value.repos : + length(each.value.branches) > 0 + ? [for branch in each.value.branches : "repo:${repo}:ref:refs/heads/${branch}"] + : length(each.value.tags) == 0 && length(each.value.environments) == 0 && !each.value.pull_request + ? ["repo:${repo}:*"] + : [] + ]), + # Tag-based subjects + flatten([for repo in each.value.repos : + [for tag in each.value.tags : "repo:${repo}:ref:refs/tags/${tag}"] + ]), + # Environment-based subjects + flatten([for repo in each.value.repos : + [for env in each.value.environments : "repo:${repo}:environment:${env}"] + ]), + # Pull request subjects + each.value.pull_request + ? [for repo in each.value.repos : "repo:${repo}:pull_request"] + : [] + ))) + } + }, + # Workflow ref condition (for reusable workflows) + each.value.workflow_ref != "" ? { + StringEquals = merge( + { "token.actions.githubusercontent.com:aud" = "sts.amazonaws.com" }, + { "token.actions.githubusercontent.com:job_workflow_ref" = each.value.workflow_ref } + ) + } : {}, + # Extra conditions + each.value.extra_conditions + ) + }] + }) + + tags = merge(local.common_tags, { + Name = "${var.name_prefix}-${each.key}" + GitHubRepos = join(",", slice(each.value.repos, 0, min(5, length(each.value.repos)))) + Purpose = "github-actions-oidc" + }) +} + +# Managed policy attachments +resource "aws_iam_role_policy_attachment" "github" { + for_each = { + for pair in flatten([ + for role_name, role in local.normalize_repo : [ + for policy_arn in role.policy_arns : { + role_name = role_name + policy_arn = policy_arn + } + ] + ]) : "${pair.role_name}-${md5(pair.policy_arn)}" => pair + } + + role = aws_iam_role.github[each.value.role_name].name + policy_arn = each.value.policy_arn +} + +# Inline policies (raw JSON) +resource "aws_iam_role_policy" "github_inline" { + for_each = { for k, v in local.normalize_repo : k => v if v.inline_policy != "" } + + name = "inline" + role = aws_iam_role.github[each.key].id + policy = each.value.inline_policy +} + +# Generated policies from statements +resource "aws_iam_role_policy" "github_generated" { + for_each = { for k, v in local.normalize_repo : k => v if length(v.policy_statements) > 0 } + + name = "generated" + role = aws_iam_role.github[each.key].id + policy = jsonencode({ + Version = "2012-10-17" + Statement = [for stmt in each.value.policy_statements : { + Sid = stmt.sid != "" ? stmt.sid : null + Effect = stmt.effect + Action = stmt.actions + Resource = stmt.resources + Condition = length(stmt.conditions) > 0 ? { + for cond in stmt.conditions : cond.test => { + "${cond.variable}" = cond.values + } + } : null + }] + }) +} + +################################################################################ +# Terraform Deploy Role (Template) +################################################################################ + +locals { + tf_role_enabled = try(var.terraform_deploy_role.enabled, false) + tf_repos = try(var.terraform_deploy_role.repos, []) + tf_repos_normalized = [for repo in local.tf_repos : + !contains(split("/", repo), "/") && var.github_org != "" + ? "${var.github_org}/${repo}" + : repo + ] +} + +resource "aws_iam_role" "terraform" { + count = local.tf_role_enabled ? 1 : 0 + + name = "${var.name_prefix}-terraform" + path = var.path + description = "GitHub Actions - Terraform deployment" + max_session_duration = min(2, var.max_session_hours_limit) * 3600 + permissions_boundary = var.permissions_boundary + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Sid = "GitHubActionsTerraform" + Effect = "Allow" + Action = "sts:AssumeRoleWithWebIdentity" + Principal = { Federated = local.provider_arn } + Condition = { + StringEquals = { "token.actions.githubusercontent.com:aud" = "sts.amazonaws.com" } + StringLike = { + "token.actions.githubusercontent.com:sub" = concat( + flatten([for repo in local.tf_repos_normalized : + length(try(var.terraform_deploy_role.branches, [])) > 0 + ? [for branch in var.terraform_deploy_role.branches : "repo:${repo}:ref:refs/heads/${branch}"] + : ["repo:${repo}:*"] + ]), + flatten([for repo in local.tf_repos_normalized : + [for env in try(var.terraform_deploy_role.environments, []) : "repo:${repo}:environment:${env}"] + ]) + ) + } + } + }] + }) + + tags = merge(local.common_tags, { + Name = "${var.name_prefix}-terraform" + Purpose = "terraform-deployment" + }) +} + +resource "aws_iam_role_policy" "terraform_state" { + count = local.tf_role_enabled && try(var.terraform_deploy_role.state_bucket, "") != "" ? 1 : 0 + + name = "terraform-state" + role = aws_iam_role.terraform[0].id + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Sid = "TerraformStateBucket" + Effect = "Allow" + Action = [ + "s3:GetObject", + "s3:PutObject", + "s3:DeleteObject" + ] + Resource = "arn:${local.partition}:s3:::${var.terraform_deploy_role.state_bucket}/${try(var.terraform_deploy_role.state_bucket_key_prefix, "*")}" + }, + { + Sid = "TerraformStateBucketList" + Effect = "Allow" + Action = ["s3:ListBucket"] + Resource = "arn:${local.partition}:s3:::${var.terraform_deploy_role.state_bucket}" + Condition = { + StringLike = { + "s3:prefix" = [try(var.terraform_deploy_role.state_bucket_key_prefix, "*")] + } + } + } + ] + }) +} + +resource "aws_iam_role_policy" "terraform_lock" { + count = local.tf_role_enabled && try(var.terraform_deploy_role.dynamodb_table, "") != "" ? 1 : 0 + + name = "terraform-lock" + role = aws_iam_role.terraform[0].id + policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Sid = "TerraformLockTable" + Effect = "Allow" + Action = [ + "dynamodb:GetItem", + "dynamodb:PutItem", + "dynamodb:DeleteItem" + ] + Resource = "arn:${local.partition}:dynamodb:*:${local.account_id}:table/${var.terraform_deploy_role.dynamodb_table}" + }] + }) +} + +# Service-specific permissions (least privilege approach) +resource "aws_iam_role_policy" "terraform_services" { + count = local.tf_role_enabled && length(try(var.terraform_deploy_role.allowed_services, [])) > 0 ? 1 : 0 + + name = "terraform-services" + role = aws_iam_role.terraform[0].id + policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Sid = "AllowedServices" + Effect = "Allow" + Action = flatten([for svc in var.terraform_deploy_role.allowed_services : "${svc}:*"]) + Resource = "*" + }] + }) +} + +# Explicit denies for dangerous actions +resource "aws_iam_role_policy" "terraform_deny" { + count = local.tf_role_enabled && length(try(var.terraform_deploy_role.denied_actions, [])) > 0 ? 1 : 0 + + name = "terraform-deny" + role = aws_iam_role.terraform[0].id + policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Sid = "ExplicitDeny" + Effect = "Deny" + Action = var.terraform_deploy_role.denied_actions + Resource = "*" + }] + }) +} + +################################################################################ +# ECR Push Role (Template) +################################################################################ + +locals { + ecr_role_enabled = try(var.ecr_push_role.enabled, false) + ecr_repos_gh = try(var.ecr_push_role.repos, []) + ecr_repos_normalized = [for repo in local.ecr_repos_gh : + !contains(split("/", repo), "/") && var.github_org != "" + ? "${var.github_org}/${repo}" + : repo + ] +} + +resource "aws_iam_role" "ecr" { + count = local.ecr_role_enabled ? 1 : 0 + + name = "${var.name_prefix}-ecr-push" + path = var.path + description = "GitHub Actions - ECR push" + max_session_duration = 3600 + permissions_boundary = var.permissions_boundary + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Sid = "GitHubActionsECR" + Effect = "Allow" + Action = "sts:AssumeRoleWithWebIdentity" + Principal = { Federated = local.provider_arn } + Condition = { + StringEquals = { "token.actions.githubusercontent.com:aud" = "sts.amazonaws.com" } + StringLike = { + "token.actions.githubusercontent.com:sub" = flatten([for repo in local.ecr_repos_normalized : + length(try(var.ecr_push_role.branches, [])) > 0 + ? [for branch in var.ecr_push_role.branches : "repo:${repo}:ref:refs/heads/${branch}"] + : ["repo:${repo}:*"] + ]) + } + } + }] + }) + + tags = merge(local.common_tags, { + Name = "${var.name_prefix}-ecr-push" + Purpose = "ecr-push" + }) +} + +resource "aws_iam_role_policy" "ecr" { + count = local.ecr_role_enabled ? 1 : 0 + + name = "ecr-push" + role = aws_iam_role.ecr[0].id + policy = jsonencode({ + Version = "2012-10-17" + Statement = concat( + [{ + Sid = "ECRAuth" + Effect = "Allow" + Action = "ecr:GetAuthorizationToken" + Resource = "*" # Required - GetAuthorizationToken doesn't support resource constraints + }], + [{ + Sid = "ECRPush" + Effect = "Allow" + Action = [ + "ecr:BatchCheckLayerAvailability", + "ecr:GetDownloadUrlForLayer", + "ecr:BatchGetImage", + "ecr:PutImage", + "ecr:InitiateLayerUpload", + "ecr:UploadLayerPart", + "ecr:CompleteLayerUpload", + "ecr:DescribeRepositories", + "ecr:DescribeImages" + ] + Resource = [for repo in try(var.ecr_push_role.ecr_repos, []) : + "arn:${local.partition}:ecr:*:${local.account_id}:repository/${repo}" + ] + }], + try(var.ecr_push_role.allow_create, false) ? [{ + Sid = "ECRCreate" + Effect = "Allow" + Action = ["ecr:CreateRepository", "ecr:TagResource"] + Resource = "arn:${local.partition}:ecr:*:${local.account_id}:repository/*" + }] : [], + try(var.ecr_push_role.allow_delete, false) ? [{ + Sid = "ECRDelete" + Effect = "Allow" + Action = ["ecr:DeleteRepository", "ecr:BatchDeleteImage"] + Resource = [for repo in try(var.ecr_push_role.ecr_repos, []) : + "arn:${local.partition}:ecr:*:${local.account_id}:repository/${repo}" + ] + }] : [] + ) + }) +} + +################################################################################ +# S3 Deploy Role (Template) +################################################################################ + +locals { + s3_role_enabled = try(var.s3_deploy_role.enabled, false) + s3_repos = try(var.s3_deploy_role.repos, []) + s3_repos_normalized = [for repo in local.s3_repos : + !contains(split("/", repo), "/") && var.github_org != "" + ? "${var.github_org}/${repo}" + : repo + ] +} + +resource "aws_iam_role" "s3_deploy" { + count = local.s3_role_enabled ? 1 : 0 + + name = "${var.name_prefix}-s3-deploy" + path = var.path + description = "GitHub Actions - S3 deployment" + max_session_duration = 3600 + permissions_boundary = var.permissions_boundary + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Sid = "GitHubActionsS3Deploy" + Effect = "Allow" + Action = "sts:AssumeRoleWithWebIdentity" + Principal = { Federated = local.provider_arn } + Condition = { + StringEquals = { "token.actions.githubusercontent.com:aud" = "sts.amazonaws.com" } + StringLike = { + "token.actions.githubusercontent.com:sub" = flatten([for repo in local.s3_repos_normalized : + length(try(var.s3_deploy_role.branches, [])) > 0 + ? [for branch in var.s3_deploy_role.branches : "repo:${repo}:ref:refs/heads/${branch}"] + : ["repo:${repo}:*"] + ]) + } + } + }] + }) + + tags = merge(local.common_tags, { + Name = "${var.name_prefix}-s3-deploy" + Purpose = "s3-static-site" + }) +} + +resource "aws_iam_role_policy" "s3_deploy" { + count = local.s3_role_enabled ? 1 : 0 + + name = "s3-deploy" + role = aws_iam_role.s3_deploy[0].id + policy = jsonencode({ + Version = "2012-10-17" + Statement = concat( + [{ + Sid = "S3Deploy" + Effect = "Allow" + Action = [ + "s3:PutObject", + "s3:GetObject", + "s3:DeleteObject", + "s3:GetObjectAcl", + "s3:PutObjectAcl" + ] + Resource = flatten([for bucket in try(var.s3_deploy_role.bucket_arns, []) : [ + for prefix in try(var.s3_deploy_role.allowed_prefixes, ["*"]) : + "${bucket}/${prefix}" + ]]) + }], + [{ + Sid = "S3List" + Effect = "Allow" + Action = ["s3:ListBucket", "s3:GetBucketLocation"] + Resource = try(var.s3_deploy_role.bucket_arns, []) + }], + length(try(var.s3_deploy_role.cloudfront_arns, [])) > 0 ? [{ + Sid = "CloudFrontInvalidate" + Effect = "Allow" + Action = "cloudfront:CreateInvalidation" + Resource = var.s3_deploy_role.cloudfront_arns + }] : [] + ) + }) +} + +################################################################################ +# Lambda Deploy Role (Template) +################################################################################ + +locals { + lambda_role_enabled = try(var.lambda_deploy_role.enabled, false) + lambda_repos = try(var.lambda_deploy_role.repos, []) + lambda_repos_normalized = [for repo in local.lambda_repos : + !contains(split("/", repo), "/") && var.github_org != "" + ? "${var.github_org}/${repo}" + : repo + ] +} + +resource "aws_iam_role" "lambda_deploy" { + count = local.lambda_role_enabled ? 1 : 0 + + name = "${var.name_prefix}-lambda-deploy" + path = var.path + description = "GitHub Actions - Lambda deployment" + max_session_duration = 3600 + permissions_boundary = var.permissions_boundary + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Sid = "GitHubActionsLambda" + Effect = "Allow" + Action = "sts:AssumeRoleWithWebIdentity" + Principal = { Federated = local.provider_arn } + Condition = { + StringEquals = { "token.actions.githubusercontent.com:aud" = "sts.amazonaws.com" } + StringLike = { + "token.actions.githubusercontent.com:sub" = flatten([for repo in local.lambda_repos_normalized : + length(try(var.lambda_deploy_role.branches, [])) > 0 + ? [for branch in var.lambda_deploy_role.branches : "repo:${repo}:ref:refs/heads/${branch}"] + : ["repo:${repo}:*"] + ]) + } + } + }] + }) + + tags = merge(local.common_tags, { + Name = "${var.name_prefix}-lambda-deploy" + Purpose = "lambda-deployment" + }) +} + +resource "aws_iam_role_policy" "lambda_deploy" { + count = local.lambda_role_enabled ? 1 : 0 + + name = "lambda-deploy" + role = aws_iam_role.lambda_deploy[0].id + policy = jsonencode({ + Version = "2012-10-17" + Statement = concat( + [{ + Sid = "LambdaDeploy" + Effect = "Allow" + Action = [ + "lambda:UpdateFunctionCode", + "lambda:UpdateFunctionConfiguration", + "lambda:GetFunction", + "lambda:GetFunctionConfiguration", + "lambda:PublishVersion", + "lambda:ListVersionsByFunction" + ] + Resource = try(var.lambda_deploy_role.function_arns, []) + }], + try(var.lambda_deploy_role.allow_create, false) ? [{ + Sid = "LambdaCreate" + Effect = "Allow" + Action = [ + "lambda:CreateFunction", + "lambda:DeleteFunction", + "lambda:TagResource", + "lambda:AddPermission", + "lambda:RemovePermission" + ] + Resource = "arn:${local.partition}:lambda:*:${local.account_id}:function:*" + }] : [], + try(var.lambda_deploy_role.allow_create, false) ? [{ + Sid = "IAMPassRole" + Effect = "Allow" + Action = "iam:PassRole" + Resource = "arn:${local.partition}:iam::${local.account_id}:role/*" + Condition = { + StringEquals = { + "iam:PassedToService" = "lambda.amazonaws.com" + } + } + }] : [], + try(var.lambda_deploy_role.allow_logs, true) ? [{ + Sid = "CloudWatchLogs" + Effect = "Allow" + Action = [ + "logs:DescribeLogGroups", + "logs:DescribeLogStreams", + "logs:GetLogEvents" + ] + Resource = "arn:${local.partition}:logs:*:${local.account_id}:log-group:/aws/lambda/*" + }] : [] + ) + }) +} + +################################################################################ +# Security Monitoring (Optional) +################################################################################ + +resource "aws_cloudwatch_log_metric_filter" "oidc_assume_role" { + count = var.enable_cloudtrail_logging && var.alarm_sns_topic_arn != "" ? 1 : 0 + + name = "github-oidc-assume-role" + pattern = "{ ($.eventName = AssumeRoleWithWebIdentity) && ($.requestParameters.roleArn = \"*${var.name_prefix}*\") }" + log_group_name = "aws-cloudtrail-logs" # Adjust to your CloudTrail log group + + metric_transformation { + name = "GitHubOIDCAssumeRole" + namespace = "Security/OIDC" + value = "1" + } +} + +resource "aws_cloudwatch_metric_alarm" "oidc_high_usage" { + count = var.enable_cloudtrail_logging && var.alarm_sns_topic_arn != "" ? 1 : 0 + + alarm_name = "github-oidc-high-usage" + alarm_description = "High number of GitHub OIDC role assumptions" + comparison_operator = "GreaterThanThreshold" + evaluation_periods = 1 + metric_name = "GitHubOIDCAssumeRole" + namespace = "Security/OIDC" + period = 300 + statistic = "Sum" + threshold = 100 + treat_missing_data = "notBreaching" + + alarm_actions = [var.alarm_sns_topic_arn] + + tags = local.common_tags +} diff --git a/terraform/modules/github-oidc/outputs.tf b/terraform/modules/github-oidc/outputs.tf new file mode 100644 index 0000000..5f54e37 --- /dev/null +++ b/terraform/modules/github-oidc/outputs.tf @@ -0,0 +1,159 @@ +################################################################################ +# GitHub OIDC Module - Outputs +################################################################################ + +output "provider_arn" { + value = local.provider_arn + description = "GitHub OIDC provider ARN" +} + +output "provider_url" { + value = "https://token.actions.githubusercontent.com" + description = "GitHub OIDC provider URL" +} + +# Custom roles +output "role_arns" { + value = { for k, v in aws_iam_role.github : k => v.arn } + description = "Map of custom role names to ARNs" +} + +output "role_names" { + value = { for k, v in aws_iam_role.github : k => v.name } + description = "Map of custom role key to IAM role names" +} + +# Template roles +output "terraform_role_arn" { + value = local.tf_role_enabled ? aws_iam_role.terraform[0].arn : null + description = "Terraform deploy role ARN" +} + +output "terraform_role_name" { + value = local.tf_role_enabled ? aws_iam_role.terraform[0].name : null + description = "Terraform deploy role name" +} + +output "ecr_role_arn" { + value = local.ecr_role_enabled ? aws_iam_role.ecr[0].arn : null + description = "ECR push role ARN" +} + +output "ecr_role_name" { + value = local.ecr_role_enabled ? aws_iam_role.ecr[0].name : null + description = "ECR push role name" +} + +output "s3_deploy_role_arn" { + value = local.s3_role_enabled ? aws_iam_role.s3_deploy[0].arn : null + description = "S3 deploy role ARN" +} + +output "s3_deploy_role_name" { + value = local.s3_role_enabled ? aws_iam_role.s3_deploy[0].name : null + description = "S3 deploy role name" +} + +output "lambda_deploy_role_arn" { + value = local.lambda_role_enabled ? aws_iam_role.lambda_deploy[0].arn : null + description = "Lambda deploy role ARN" +} + +output "lambda_deploy_role_name" { + value = local.lambda_role_enabled ? aws_iam_role.lambda_deploy[0].name : null + description = "Lambda deploy role name" +} + +# All role ARNs combined +output "all_role_arns" { + value = merge( + { for k, v in aws_iam_role.github : k => v.arn }, + local.tf_role_enabled ? { terraform = aws_iam_role.terraform[0].arn } : {}, + local.ecr_role_enabled ? { ecr = aws_iam_role.ecr[0].arn } : {}, + local.s3_role_enabled ? { s3_deploy = aws_iam_role.s3_deploy[0].arn } : {}, + local.lambda_role_enabled ? { lambda_deploy = aws_iam_role.lambda_deploy[0].arn } : {} + ) + description = "All role ARNs (custom + templates)" +} + +# Security outputs +output "iam_path" { + value = var.path + description = "IAM path used for roles (useful for permissions boundaries)" +} + +output "security_recommendations" { + value = { + permissions_boundary_set = var.permissions_boundary != null + max_session_limited = var.max_session_hours_limit < 12 + wildcard_repos_denied = var.deny_wildcard_repos + cloudtrail_monitoring = var.enable_cloudtrail_logging + } + description = "Security configuration status" +} + +# Workflow configuration helper +output "github_actions_config" { + value = { + aws_region = local.region + roles = merge( + { for k, v in aws_iam_role.github : k => { + arn = v.arn + name = v.name + }}, + local.tf_role_enabled ? { terraform = { + arn = aws_iam_role.terraform[0].arn + name = aws_iam_role.terraform[0].name + }} : {}, + local.ecr_role_enabled ? { ecr = { + arn = aws_iam_role.ecr[0].arn + name = aws_iam_role.ecr[0].name + }} : {}, + local.s3_role_enabled ? { s3_deploy = { + arn = aws_iam_role.s3_deploy[0].arn + name = aws_iam_role.s3_deploy[0].name + }} : {}, + local.lambda_role_enabled ? { lambda_deploy = { + arn = aws_iam_role.lambda_deploy[0].arn + name = aws_iam_role.lambda_deploy[0].name + }} : {} + ) + } + description = "Configuration for GitHub Actions workflows" +} + +# Example workflow snippets +output "workflow_examples" { + value = { + basic = <<-EOF + # .github/workflows/deploy.yml + permissions: + id-token: write + contents: read + + steps: + - uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: <ROLE_ARN> + aws-region: ${local.region} + role-session-name: github-actions-${"$"}{{ github.run_id }} + EOF + + with_environment = <<-EOF + # .github/workflows/deploy.yml + jobs: + deploy: + runs-on: ubuntu-latest + environment: production # Requires approval if configured + permissions: + id-token: write + contents: read + steps: + - uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: <ROLE_ARN> + aws-region: ${local.region} + EOF + } + description = "Example GitHub Actions workflow snippets" +} diff --git a/terraform/modules/github-oidc/tests/basic.tftest.hcl b/terraform/modules/github-oidc/tests/basic.tftest.hcl new file mode 100644 index 0000000..d861e2e --- /dev/null +++ b/terraform/modules/github-oidc/tests/basic.tftest.hcl @@ -0,0 +1,210 @@ +################################################################################ +# GitHub OIDC Module - Basic Tests +# Uses Terraform native testing framework +################################################################################ + +# Mock AWS provider for unit tests +mock_provider "aws" { + mock_data "aws_caller_identity" { + defaults = { + account_id = "123456789012" + arn = "arn:aws:iam::123456789012:user/test" + user_id = "AIDATEST123456789" + } + } + + mock_data "aws_region" { + defaults = { + name = "us-east-1" + } + } + + mock_data "aws_partition" { + defaults = { + partition = "aws" + dns_suffix = "amazonaws.com" + } + } +} + +# Test: Basic role creation +run "basic_role_creation" { + command = plan + + variables { + github_org = "test-org" + roles = { + deploy = { + repos = ["test-repo"] + branches = ["main"] + policy_statements = [{ + sid = "TestAccess" + actions = ["s3:GetObject"] + resources = ["arn:aws:s3:::test-bucket/*"] + }] + } + } + tags = { + Environment = "test" + } + } + + # Verify OIDC provider is created + assert { + condition = aws_iam_openid_connect_provider.github[0].url == "https://token.actions.githubusercontent.com" + error_message = "OIDC provider URL is incorrect" + } + + # Verify role is created with correct name + assert { + condition = aws_iam_role.github["deploy"].name == "github-deploy" + error_message = "Role name should be github-deploy" + } + + # Verify IAM path is set correctly + assert { + condition = aws_iam_role.github["deploy"].path == "/github-actions/" + error_message = "Role path should be /github-actions/" + } +} + +# Test: Repository normalization with org prefix +run "repo_normalization" { + command = plan + + variables { + github_org = "my-org" + roles = { + test = { + repos = ["repo-without-org"] # Should become my-org/repo-without-org + branches = ["main"] + } + } + } + + # Role should be created (validates normalization works) + assert { + condition = aws_iam_role.github["test"].name == "github-test" + error_message = "Role should be created with normalized repo" + } +} + +# Test: Multiple roles with different configurations +run "multiple_roles" { + command = plan + + variables { + github_org = "test-org" + roles = { + validate = { + repos = ["app"] + pull_request = true + max_session_hours = 1 + } + deploy = { + repos = ["app"] + branches = ["main"] + max_session_hours = 2 + } + release = { + repos = ["app"] + tags = ["v*"] + } + } + } + + # Verify all roles are created + assert { + condition = length(aws_iam_role.github) == 3 + error_message = "Should create 3 roles" + } +} + +# Test: Terraform deploy template role +run "terraform_template_role" { + command = plan + + variables { + github_org = "test-org" + terraform_deploy_role = { + enabled = true + repos = ["infra"] + branches = ["main"] + state_bucket = "my-tf-state" + dynamodb_table = "terraform-locks" + } + } + + # Verify Terraform role is created + assert { + condition = aws_iam_role.terraform[0].name == "github-terraform" + error_message = "Terraform role should be created" + } +} + +# Test: ECR push template role +run "ecr_template_role" { + command = plan + + variables { + github_org = "test-org" + ecr_push_role = { + enabled = true + repos = ["app"] + branches = ["main"] + ecr_repos = ["my-ecr-repo"] + } + } + + # Verify ECR role is created + assert { + condition = aws_iam_role.ecr[0].name == "github-ecr-push" + error_message = "ECR role should be created" + } +} + +# Test: Session duration capping +run "session_duration_capping" { + command = plan + + variables { + github_org = "test-org" + max_session_hours_limit = 2 + roles = { + test = { + repos = ["app"] + branches = ["main"] + max_session_hours = 4 # Should be capped to 2 + } + } + } + + # Verify session duration is capped (2 hours = 7200 seconds) + assert { + condition = aws_iam_role.github["test"].max_session_duration == 7200 + error_message = "Session duration should be capped at 2 hours (7200 seconds)" + } +} + +# Test: Existing provider ARN (no provider creation) +run "existing_provider" { + command = plan + + variables { + create_provider = false + provider_arn = "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com" + github_org = "test-org" + roles = { + test = { + repos = ["app"] + branches = ["main"] + } + } + } + + # Verify no provider is created + assert { + condition = length(aws_iam_openid_connect_provider.github) == 0 + error_message = "Should not create provider when create_provider=false" + } +} diff --git a/terraform/modules/github-oidc/tests/security.tftest.hcl b/terraform/modules/github-oidc/tests/security.tftest.hcl new file mode 100644 index 0000000..0aa52b5 --- /dev/null +++ b/terraform/modules/github-oidc/tests/security.tftest.hcl @@ -0,0 +1,203 @@ +################################################################################ +# GitHub OIDC Module - Security Tests +# Validates security best practices are enforced +################################################################################ + +mock_provider "aws" { + mock_data "aws_caller_identity" { + defaults = { + account_id = "123456789012" + } + } + + mock_data "aws_region" { + defaults = { + name = "us-east-1" + } + } + + mock_data "aws_partition" { + defaults = { + partition = "aws" + dns_suffix = "amazonaws.com" + } + } +} + +# Test: Wildcard repos denied by default +run "wildcard_repos_denied" { + command = plan + + variables { + github_org = "test-org" + deny_wildcard_repos = true + roles = { + test = { + repos = ["*"] # Wildcard - should fail without workflow_ref + branches = ["main"] + } + } + } + + expect_failures = [ + # This should fail validation because wildcard repos require workflow_ref + var.roles + ] +} + +# Test: Wildcard repos allowed with workflow_ref +run "wildcard_repos_with_workflow_ref" { + command = plan + + variables { + github_org = "test-org" + deny_wildcard_repos = true + roles = { + test = { + repos = ["*"] + workflow_ref = "test-org/workflows/.github/workflows/deploy.yml@main" + } + } + } + + # Should succeed because workflow_ref is specified + assert { + condition = aws_iam_role.github["test"].name == "github-test" + error_message = "Should allow wildcard with workflow_ref" + } +} + +# Test: IAM path isolation +run "iam_path_isolation" { + command = plan + + variables { + github_org = "test-org" + path = "/github-actions/" + roles = { + test = { + repos = ["app"] + branches = ["main"] + } + } + } + + # Verify path is set for role isolation + assert { + condition = aws_iam_role.github["test"].path == "/github-actions/" + error_message = "Role should use isolated IAM path" + } +} + +# Test: Permissions boundary is applied +run "permissions_boundary_applied" { + command = plan + + variables { + github_org = "test-org" + permissions_boundary = "arn:aws:iam::123456789012:policy/TestBoundary" + roles = { + test = { + repos = ["app"] + branches = ["main"] + } + } + } + + # Verify permissions boundary is set + assert { + condition = aws_iam_role.github["test"].permissions_boundary == "arn:aws:iam::123456789012:policy/TestBoundary" + error_message = "Permissions boundary should be applied to role" + } +} + +# Test: Terraform role has explicit denies +run "terraform_role_explicit_denies" { + command = plan + + variables { + github_org = "test-org" + terraform_deploy_role = { + enabled = true + repos = ["infra"] + branches = ["main"] + denied_actions = ["iam:CreateUser", "organizations:*"] + } + } + + # Verify deny policy is created + assert { + condition = aws_iam_role_policy.terraform_deny[0].name == "terraform-deny" + error_message = "Terraform deny policy should be created" + } +} + +# Test: ECR role requires explicit repos +run "ecr_explicit_repos_required" { + command = plan + + variables { + github_org = "test-org" + ecr_push_role = { + enabled = true + repos = ["app"] + ecr_repos = ["my-ecr-repo"] # Explicit ECR repo required + } + } + + # Should succeed with explicit ECR repos + assert { + condition = aws_iam_role.ecr[0].name == "github-ecr-push" + error_message = "ECR role should be created with explicit repos" + } +} + +# Test: Role tags include security metadata +run "security_tags" { + command = plan + + variables { + github_org = "test-org" + roles = { + test = { + repos = ["app"] + branches = ["main"] + } + } + tags = { + Environment = "production" + } + } + + # Verify tags include ManagedBy and Module + assert { + condition = aws_iam_role.github["test"].tags["ManagedBy"] == "terraform" + error_message = "Role should have ManagedBy tag" + } + + assert { + condition = aws_iam_role.github["test"].tags["Module"] == "github-oidc" + error_message = "Role should have Module tag" + } +} + +# Test: Trust policy uses StringLike for subject claims +run "trust_policy_string_like" { + command = plan + + variables { + github_org = "test-org" + roles = { + test = { + repos = ["app"] + branches = ["main", "develop"] # Multiple branches + } + } + } + + # Role should be created with proper trust policy + assert { + condition = aws_iam_role.github["test"].assume_role_policy != "" + error_message = "Trust policy should be set" + } +} diff --git a/terraform/modules/github-oidc/variables.tf b/terraform/modules/github-oidc/variables.tf new file mode 100644 index 0000000..4633ba9 --- /dev/null +++ b/terraform/modules/github-oidc/variables.tf @@ -0,0 +1,248 @@ +################################################################################ +# GitHub OIDC Module - Variables +# With AWS/Terraform/Security Best Practices Validation +################################################################################ + +variable "create_provider" { + type = bool + default = true + description = "Create the OIDC provider. Set false if already exists in account." +} + +variable "provider_arn" { + type = string + default = "" + description = "Existing OIDC provider ARN (required if create_provider = false)" + + validation { + condition = var.provider_arn == "" || can(regex("^arn:aws:iam::[0-9]{12}:oidc-provider/", var.provider_arn)) + error_message = "Provider ARN must be a valid IAM OIDC provider ARN." + } +} + +variable "github_org" { + type = string + default = "" + description = "GitHub organization. If set, prepended to repos that don't include org." + + validation { + condition = var.github_org == "" || can(regex("^[a-zA-Z0-9][a-zA-Z0-9-]*$", var.github_org)) + error_message = "GitHub org must be alphanumeric with hyphens (no leading hyphen)." + } +} + +variable "name_prefix" { + type = string + default = "github" + description = "Prefix for IAM role names" + + validation { + condition = can(regex("^[a-zA-Z][a-zA-Z0-9-_]*$", var.name_prefix)) + error_message = "Name prefix must start with letter, contain only alphanumeric, hyphens, underscores." + } +} + +variable "path" { + type = string + default = "/github-actions/" + description = "IAM path for roles (enables easier permission boundaries)" + + validation { + condition = can(regex("^/[a-zA-Z0-9/_-]*/$", var.path)) + error_message = "IAM path must start and end with /, contain only alphanumeric, /, -, _." + } +} + +variable "permissions_boundary" { + type = string + default = null + description = "ARN of permissions boundary to attach to roles (RECOMMENDED for defense-in-depth)" + + validation { + condition = var.permissions_boundary == null || can(regex("^arn:aws:iam::[0-9]{12}:policy/", var.permissions_boundary)) + error_message = "Permissions boundary must be a valid IAM policy ARN." + } +} + +variable "require_permissions_boundary" { + type = bool + default = false + description = "Require a permissions boundary to be set (security guardrail)" +} + +variable "max_session_hours_limit" { + type = number + default = 4 + description = "Maximum allowed session duration in hours (caps role max_session_hours)" + + validation { + condition = var.max_session_hours_limit >= 1 && var.max_session_hours_limit <= 12 + error_message = "Max session hours must be between 1 and 12." + } +} + +variable "deny_wildcard_repos" { + type = bool + default = true + description = "Deny roles that allow all repos (*). Set false only if using workflow_ref restriction." +} + +variable "roles" { + type = map(object({ + # Repository configuration + repos = list(string) # GitHub repos (owner/repo or just repo if github_org set) + branches = optional(list(string), []) # Branch restrictions (empty = all branches) + tags = optional(list(string), []) # Tag restrictions (e.g., ["v*", "release-*"]) + environments = optional(list(string), []) # GitHub environment restrictions + + # Event type restrictions + pull_request = optional(bool, false) # Allow from pull_request events + workflow_ref = optional(string, "") # Restrict to specific reusable workflow + + # IAM configuration + policy_arns = optional(list(string), []) # Managed policy ARNs to attach + inline_policy = optional(string, "") # Inline policy JSON + policy_statements = optional(list(object({ # Policy statements to generate + sid = optional(string, "") + effect = optional(string, "Allow") + actions = list(string) + resources = list(string) + conditions = optional(list(object({ + test = string + variable = string + values = list(string) + })), []) + })), []) + + # Session configuration + max_session_hours = optional(number, 1) # Maximum session duration (1-12) + + # Extra trust conditions + extra_conditions = optional(map(map(list(string))), {}) # Additional assume role conditions + })) + default = {} + description = "Map of role configurations for GitHub Actions" + + validation { + condition = alltrue([ + for k, v in var.roles : length(v.repos) > 0 + ]) + error_message = "Each role must specify at least one repository." + } + + validation { + condition = alltrue([ + for k, v in var.roles : v.max_session_hours >= 1 && v.max_session_hours <= 12 + ]) + error_message = "Role max_session_hours must be between 1 and 12." + } + + validation { + condition = alltrue([ + for k, v in var.roles : alltrue([ + for repo in v.repos : can(regex("^[a-zA-Z0-9][a-zA-Z0-9-_.]*/[a-zA-Z0-9][a-zA-Z0-9-_.]*$|^[a-zA-Z0-9][a-zA-Z0-9-_.]*$|^\\*$", repo)) + ]) + ]) + error_message = "Repository names must be valid GitHub repo format (owner/repo or repo)." + } +} + +# Pre-built role templates +variable "terraform_deploy_role" { + type = object({ + enabled = optional(bool, false) + repos = optional(list(string), []) + branches = optional(list(string), ["main"]) + environments = optional(list(string), []) + state_bucket = optional(string, "") + state_bucket_key_prefix = optional(string, "*") # Limit to specific paths + dynamodb_table = optional(string, "") + allowed_services = optional(list(string), []) # Limit to specific AWS services + denied_actions = optional(list(string), [ # Explicit denies for safety + "iam:CreateUser", + "iam:CreateAccessKey", + "iam:DeleteAccountPasswordPolicy", + "organizations:*", + "account:*" + ]) + }) + default = {} + description = "Pre-configured role for Terraform deployments" +} + +variable "ecr_push_role" { + type = object({ + enabled = optional(bool, false) + repos = optional(list(string), []) + branches = optional(list(string), ["main"]) + ecr_repos = optional(list(string), []) # Specific ECR repos (no default wildcard) + allow_create = optional(bool, false) + allow_delete = optional(bool, false) # Explicit opt-in for delete + }) + default = {} + description = "Pre-configured role for ECR push operations" + + validation { + condition = !try(var.ecr_push_role.enabled, false) || length(try(var.ecr_push_role.ecr_repos, [])) > 0 + error_message = "ECR push role requires explicit ecr_repos list (no wildcards for security)." + } +} + +variable "s3_deploy_role" { + type = object({ + enabled = optional(bool, false) + repos = optional(list(string), []) + branches = optional(list(string), ["main"]) + bucket_arns = optional(list(string), []) + allowed_prefixes = optional(list(string), ["*"]) # Limit to specific paths + cloudfront_arns = optional(list(string), []) + }) + default = {} + description = "Pre-configured role for S3 static site deployments" + + validation { + condition = !try(var.s3_deploy_role.enabled, false) || length(try(var.s3_deploy_role.bucket_arns, [])) > 0 + error_message = "S3 deploy role requires explicit bucket_arns list." + } +} + +variable "lambda_deploy_role" { + type = object({ + enabled = optional(bool, false) + repos = optional(list(string), []) + branches = optional(list(string), ["main"]) + function_arns = optional(list(string), []) + allow_create = optional(bool, false) + allow_logs = optional(bool, true) # Allow CloudWatch Logs access + }) + default = {} + description = "Pre-configured role for Lambda deployments" + + validation { + condition = !try(var.lambda_deploy_role.enabled, false) || length(try(var.lambda_deploy_role.function_arns, [])) > 0 + error_message = "Lambda deploy role requires explicit function_arns list." + } +} + +variable "enable_cloudtrail_logging" { + type = bool + default = true + description = "Create CloudWatch metric alarms for OIDC role assumptions" +} + +variable "alarm_sns_topic_arn" { + type = string + default = "" + description = "SNS topic ARN for security alarms" +} + +variable "tags" { + type = map(string) + default = {} + description = "Tags to apply to all resources" + + validation { + condition = !contains(keys(var.tags), "Name") + error_message = "Name tag is auto-generated, do not specify in tags variable." + } +} diff --git a/terraform/modules/github-oidc/versions.tf b/terraform/modules/github-oidc/versions.tf new file mode 100644 index 0000000..a9b5b3c --- /dev/null +++ b/terraform/modules/github-oidc/versions.tf @@ -0,0 +1,18 @@ +################################################################################ +# GitHub OIDC Module - Versions +################################################################################ + +terraform { + required_version = ">= 1.5.0" + + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 5.0" + } + tls = { + source = "hashicorp/tls" + version = ">= 4.0" + } + } +} diff --git a/terraform/modules/iam-account-settings/README.md b/terraform/modules/iam-account-settings/README.md new file mode 100644 index 0000000..d9ea987 --- /dev/null +++ b/terraform/modules/iam-account-settings/README.md @@ -0,0 +1,50 @@ +# iam-account-settings + +IAM Account Settings Module + +## Usage + +```hcl +module "iam_account_settings" { + source = "../modules/iam-account-settings" + + # Required variables + password_policy = "" + + # Optional: see variables.tf for all options +} +``` + +## Requirements + +| Name | Version | +|------|---------| +| terraform | >= 1.5.0 | +| aws | >= 5.0 | + +## Inputs + +| Name | Description | Type | Required | +|------|-------------|------|----------| +| account_alias | AWS account alias (appears in sign-in URL) | `string` | no | +| password_policy | | `object({` | yes | +| enable_password_policy | Enable custom password policy | `bool` | no | +| enforce_mfa | Create IAM policy to enforce MFA for all actions | `bool` | no | +| mfa_grace_period_days | Days new users have before MFA is required (0 = immediate) | `number` | no | +| mfa_exempt_roles | Role names exempt from MFA requirement | `list(string)` | no | +| tags | | `map(string)` | no | + +## Outputs + +| Name | Description | +|------|-------------| +| account_alias | AWS account alias | +| signin_url | | +| password_policy | | +| mfa_enforcement_policy_arn | MFA enforcement policy ARN | +| mfa_required_group | Group name for users requiring MFA | +| mfa_scp_template_policy | Template policy for MFA SCP (copy to Organizations) | + +## License + +Apache 2.0 - See LICENSE for details. diff --git a/terraform/modules/iam-account-settings/main.tf b/terraform/modules/iam-account-settings/main.tf new file mode 100644 index 0000000..a2118e1 --- /dev/null +++ b/terraform/modules/iam-account-settings/main.tf @@ -0,0 +1,338 @@ +################################################################################ +# IAM Account Settings Module +# +# Account-level IAM security settings: +# - Password policy (complexity, rotation, reuse) +# - MFA enforcement via SCP/IAM policy +# - Account alias +# - SAML providers +# +# Usage: +# module "iam_settings" { +# source = "../modules/iam-account-settings" +# +# account_alias = "mycompany-prod" +# +# password_policy = { +# minimum_length = 14 +# require_symbols = true +# require_numbers = true +# require_uppercase = true +# require_lowercase = true +# max_age_days = 90 +# password_reuse_prevention = 24 +# allow_users_to_change = true +# } +# +# enforce_mfa = true +# } +################################################################################ + +terraform { + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 5.0" + } + } +} + +variable "account_alias" { + type = string + default = "" + description = "AWS account alias (appears in sign-in URL)" +} + +variable "password_policy" { + type = object({ + minimum_length = optional(number, 14) + require_symbols = optional(bool, true) + require_numbers = optional(bool, true) + require_uppercase_characters = optional(bool, true) + require_lowercase_characters = optional(bool, true) + allow_users_to_change_password = optional(bool, true) + max_password_age = optional(number, 90) + password_reuse_prevention = optional(number, 24) + hard_expiry = optional(bool, false) + }) + default = {} + description = "Password policy settings" +} + +variable "enable_password_policy" { + type = bool + default = true + description = "Enable custom password policy" +} + +variable "enforce_mfa" { + type = bool + default = false + description = "Create IAM policy to enforce MFA for all actions" +} + +variable "mfa_grace_period_days" { + type = number + default = 0 + description = "Days new users have before MFA is required (0 = immediate)" +} + +variable "mfa_exempt_roles" { + type = list(string) + default = [] + description = "Role names exempt from MFA requirement" +} + +variable "tags" { + type = map(string) + default = {} +} + +################################################################################ +# Account Alias +################################################################################ + +resource "aws_iam_account_alias" "main" { + count = var.account_alias != "" ? 1 : 0 + account_alias = var.account_alias +} + +################################################################################ +# Password Policy +################################################################################ + +resource "aws_iam_account_password_policy" "main" { + count = var.enable_password_policy ? 1 : 0 + + minimum_password_length = var.password_policy.minimum_length + require_symbols = var.password_policy.require_symbols + require_numbers = var.password_policy.require_numbers + require_uppercase_characters = var.password_policy.require_uppercase_characters + require_lowercase_characters = var.password_policy.require_lowercase_characters + allow_users_to_change_password = var.password_policy.allow_users_to_change_password + max_password_age = var.password_policy.max_password_age + password_reuse_prevention = var.password_policy.password_reuse_prevention + hard_expiry = var.password_policy.hard_expiry +} + +################################################################################ +# MFA Enforcement Policy +################################################################################ + +# This policy denies all actions (except MFA setup) if MFA is not present +resource "aws_iam_policy" "enforce_mfa" { + count = var.enforce_mfa ? 1 : 0 + + name = "EnforceMFA" + description = "Denies all actions except MFA setup when MFA is not enabled" + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Sid = "AllowViewAccountInfo" + Effect = "Allow" + Action = [ + "iam:GetAccountPasswordPolicy", + "iam:ListVirtualMFADevices" + ] + Resource = "*" + }, + { + Sid = "AllowManageOwnPasswords" + Effect = "Allow" + Action = [ + "iam:ChangePassword", + "iam:GetUser" + ] + Resource = "arn:aws:iam::*:user/$${aws:username}" + }, + { + Sid = "AllowManageOwnAccessKeys" + Effect = "Allow" + Action = [ + "iam:CreateAccessKey", + "iam:DeleteAccessKey", + "iam:ListAccessKeys", + "iam:UpdateAccessKey", + "iam:GetAccessKeyLastUsed" + ] + Resource = "arn:aws:iam::*:user/$${aws:username}" + }, + { + Sid = "AllowManageOwnSigningCertificates" + Effect = "Allow" + Action = [ + "iam:DeleteSigningCertificate", + "iam:ListSigningCertificates", + "iam:UpdateSigningCertificate", + "iam:UploadSigningCertificate" + ] + Resource = "arn:aws:iam::*:user/$${aws:username}" + }, + { + Sid = "AllowManageOwnSSHPublicKeys" + Effect = "Allow" + Action = [ + "iam:DeleteSSHPublicKey", + "iam:GetSSHPublicKey", + "iam:ListSSHPublicKeys", + "iam:UpdateSSHPublicKey", + "iam:UploadSSHPublicKey" + ] + Resource = "arn:aws:iam::*:user/$${aws:username}" + }, + { + Sid = "AllowManageOwnGitCredentials" + Effect = "Allow" + Action = [ + "iam:CreateServiceSpecificCredential", + "iam:DeleteServiceSpecificCredential", + "iam:ListServiceSpecificCredentials", + "iam:ResetServiceSpecificCredential", + "iam:UpdateServiceSpecificCredential" + ] + Resource = "arn:aws:iam::*:user/$${aws:username}" + }, + { + Sid = "AllowManageOwnVirtualMFADevice" + Effect = "Allow" + Action = [ + "iam:CreateVirtualMFADevice", + "iam:DeleteVirtualMFADevice" + ] + Resource = "arn:aws:iam::*:mfa/*" + }, + { + Sid = "AllowManageOwnUserMFA" + Effect = "Allow" + Action = [ + "iam:DeactivateMFADevice", + "iam:EnableMFADevice", + "iam:ListMFADevices", + "iam:ResyncMFADevice" + ] + Resource = "arn:aws:iam::*:user/$${aws:username}" + }, + { + Sid = "DenyAllExceptListedIfNoMFA" + Effect = "Deny" + NotAction = [ + "iam:CreateVirtualMFADevice", + "iam:EnableMFADevice", + "iam:GetUser", + "iam:GetMFADevice", + "iam:ListMFADevices", + "iam:ListVirtualMFADevices", + "iam:ResyncMFADevice", + "sts:GetSessionToken", + "iam:ChangePassword", + "iam:GetAccountPasswordPolicy" + ] + Resource = "*" + Condition = { + BoolIfExists = { + "aws:MultiFactorAuthPresent" = "false" + } + } + } + ] + }) + + tags = merge(var.tags, { Name = "EnforceMFA" }) +} + +# Group for users who must have MFA +resource "aws_iam_group" "mfa_required" { + count = var.enforce_mfa ? 1 : 0 + name = "MFARequired" +} + +resource "aws_iam_group_policy_attachment" "mfa_required" { + count = var.enforce_mfa ? 1 : 0 + group = aws_iam_group.mfa_required[0].name + policy_arn = aws_iam_policy.enforce_mfa[0].arn +} + +################################################################################ +# MFA Enforcement SCP (for Organizations) +################################################################################ + +# This can be attached at the OU level for organization-wide enforcement +resource "aws_iam_policy" "mfa_scp_template" { + count = var.enforce_mfa ? 1 : 0 + + name = "MFA-SCP-Template" + description = "Template SCP for MFA enforcement (apply via aws_organizations_policy)" + + # Note: This is an IAM policy format - for SCP, use this as a template + # SCPs don't support aws:MultiFactorAuthPresent for all actions + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Sid = "DenyStopAndTerminateWithoutMFA" + Effect = "Deny" + Action = [ + "ec2:StopInstances", + "ec2:TerminateInstances", + "rds:DeleteDBInstance", + "rds:DeleteDBCluster", + "s3:DeleteBucket", + "iam:DeleteUser", + "iam:DeleteRole" + ] + Resource = "*" + Condition = { + BoolIfExists = { + "aws:MultiFactorAuthPresent" = "false" + } + } + } + ] + }) + + tags = merge(var.tags, { Name = "MFA-SCP-Template" }) +} + +################################################################################ +# Outputs +################################################################################ + +output "account_alias" { + value = var.account_alias != "" ? var.account_alias : null + description = "AWS account alias" +} + +output "signin_url" { + value = var.account_alias != "" ? "https://${var.account_alias}.signin.aws.amazon.com/console" : null + description = "AWS console sign-in URL" +} + +output "password_policy" { + value = var.enable_password_policy ? { + minimum_length = var.password_policy.minimum_length + require_symbols = var.password_policy.require_symbols + require_numbers = var.password_policy.require_numbers + require_uppercase = var.password_policy.require_uppercase_characters + require_lowercase = var.password_policy.require_lowercase_characters + max_age_days = var.password_policy.max_password_age + reuse_prevention = var.password_policy.password_reuse_prevention + } : null + description = "Password policy settings" +} + +output "mfa_enforcement_policy_arn" { + value = var.enforce_mfa ? aws_iam_policy.enforce_mfa[0].arn : null + description = "MFA enforcement policy ARN" +} + +output "mfa_required_group" { + value = var.enforce_mfa ? aws_iam_group.mfa_required[0].name : null + description = "Group name for users requiring MFA" +} + +output "mfa_scp_template_policy" { + value = var.enforce_mfa ? aws_iam_policy.mfa_scp_template[0].policy : null + description = "Template policy for MFA SCP (copy to Organizations)" +} diff --git a/terraform/modules/iam-role/README.md b/terraform/modules/iam-role/README.md new file mode 100644 index 0000000..b324b9f --- /dev/null +++ b/terraform/modules/iam-role/README.md @@ -0,0 +1,60 @@ +# iam-role + +IAM Role Module + +## Usage + +```hcl +module "iam_role" { + source = "../modules/iam-role" + + # Required variables + name = "" + + # Optional: see variables.tf for all options +} +``` + +## Requirements + +| Name | Version | +|------|---------| +| terraform | >= 1.5.0 | +| aws | >= 5.0 | + +## Inputs + +| Name | Description | Type | Required | +|------|-------------|------|----------| +| name | Role name | `string` | yes | +| role_type | Type: service, cross-account, oidc | `string` | no | +| description | Role description | `string` | no | +| path | IAM path | `string` | no | +| max_session_duration | Maximum session duration in seconds (1-12 hours) | `number` | no | +| service | AWS service principal (e.g., lambda.amazonaws.com) | `string` | no | +| services | Multiple service principals | `list(string)` | no | +| trusted_account_ids | Account IDs that can assume this role | `list(string)` | no | +| trusted_role_arns | Specific role ARNs that can assume this role | `list(string)` | no | +| require_mfa | Require MFA for cross-account assumption | `bool` | no | +| require_external_id | External ID required for assumption | `string` | no | +| oidc_provider_arn | OIDC provider ARN | `string` | no | +| oidc_subjects | Allowed OIDC subjects (e.g., repo:org/repo:*) | `list(string)` | no | +| oidc_audiences | OIDC audiences | `list(string)` | no | +| managed_policies | List of managed policy ARNs to attach | `list(string)` | no | + +*...and 4 more variables. See `variables.tf` for complete list.* + +## Outputs + +| Name | Description | +|------|-------------| +| role_arn | Role ARN | +| role_name | Role name | +| role_id | Role unique ID | +| instance_profile_arn | Instance profile ARN | +| instance_profile_name | Instance profile name | +| assume_role_command | | + +## License + +Apache 2.0 - See LICENSE for details. diff --git a/terraform/modules/iam-role/main.tf b/terraform/modules/iam-role/main.tf new file mode 100644 index 0000000..c59d576 --- /dev/null +++ b/terraform/modules/iam-role/main.tf @@ -0,0 +1,352 @@ +################################################################################ +# IAM Role Module +# +# Common IAM role patterns: +# - Service roles (EC2, Lambda, ECS, etc.) +# - Cross-account roles (OrganizationAccountAccessRole pattern) +# - OIDC roles (GitHub Actions, EKS service accounts) +# - Instance profiles +# +# Usage: +# # Lambda execution role +# module "lambda_role" { +# source = "../modules/iam-role" +# +# name = "my-lambda" +# role_type = "service" +# service = "lambda.amazonaws.com" +# managed_policies = ["arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"] +# } +# +# # GitHub Actions OIDC +# module "github_role" { +# source = "../modules/iam-role" +# +# name = "github-deploy" +# role_type = "oidc" +# oidc_provider_arn = aws_iam_openid_connect_provider.github.arn +# oidc_subjects = ["repo:myorg/myrepo:*"] +# } +################################################################################ + +terraform { + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 5.0" + } + } +} + +variable "name" { + type = string + description = "Role name" +} + +variable "role_type" { + type = string + default = "service" + description = "Type: service, cross-account, oidc" + + validation { + condition = contains(["service", "cross-account", "oidc"], var.role_type) + error_message = "Must be service, cross-account, or oidc" + } +} + +variable "description" { + type = string + default = "" + description = "Role description" +} + +variable "path" { + type = string + default = "/" + description = "IAM path" +} + +variable "max_session_duration" { + type = number + default = 3600 + description = "Maximum session duration in seconds (1-12 hours)" +} + +# Service role settings +variable "service" { + type = string + default = "" + description = "AWS service principal (e.g., lambda.amazonaws.com)" +} + +variable "services" { + type = list(string) + default = [] + description = "Multiple service principals" +} + +# Cross-account settings +variable "trusted_account_ids" { + type = list(string) + default = [] + description = "Account IDs that can assume this role" +} + +variable "trusted_role_arns" { + type = list(string) + default = [] + description = "Specific role ARNs that can assume this role" +} + +variable "require_mfa" { + type = bool + default = false + description = "Require MFA for cross-account assumption" +} + +variable "require_external_id" { + type = string + default = "" + description = "External ID required for assumption" +} + +# OIDC settings +variable "oidc_provider_arn" { + type = string + default = "" + description = "OIDC provider ARN" +} + +variable "oidc_subjects" { + type = list(string) + default = [] + description = "Allowed OIDC subjects (e.g., repo:org/repo:*)" +} + +variable "oidc_audiences" { + type = list(string) + default = ["sts.amazonaws.com"] + description = "OIDC audiences" +} + +# Policies +variable "managed_policies" { + type = list(string) + default = [] + description = "List of managed policy ARNs to attach" +} + +variable "inline_policies" { + type = map(string) + default = {} + description = "Map of inline policy name -> JSON policy document" +} + +# Instance profile +variable "create_instance_profile" { + type = bool + default = false + description = "Create an instance profile (for EC2)" +} + +# Permissions boundary +variable "permissions_boundary" { + type = string + default = "" + description = "Permissions boundary ARN" +} + +variable "tags" { + type = map(string) + default = {} +} + +################################################################################ +# Data Sources +################################################################################ + +data "aws_caller_identity" "current" {} + +locals { + service_principals = var.service != "" ? [var.service] : var.services + + description = var.description != "" ? var.description : ( + var.role_type == "service" ? "Service role for ${join(", ", local.service_principals)}" : + var.role_type == "cross-account" ? "Cross-account role" : + "OIDC role" + ) +} + +################################################################################ +# Assume Role Policy +################################################################################ + +data "aws_iam_policy_document" "assume_role" { + # Service role trust + dynamic "statement" { + for_each = var.role_type == "service" && length(local.service_principals) > 0 ? [1] : [] + content { + effect = "Allow" + actions = ["sts:AssumeRole"] + principals { + type = "Service" + identifiers = local.service_principals + } + } + } + + # Cross-account trust (account IDs) + dynamic "statement" { + for_each = var.role_type == "cross-account" && length(var.trusted_account_ids) > 0 ? [1] : [] + content { + effect = "Allow" + actions = ["sts:AssumeRole"] + principals { + type = "AWS" + identifiers = [for id in var.trusted_account_ids : "arn:aws:iam::${id}:root"] + } + + dynamic "condition" { + for_each = var.require_mfa ? [1] : [] + content { + test = "Bool" + variable = "aws:MultiFactorAuthPresent" + values = ["true"] + } + } + + dynamic "condition" { + for_each = var.require_external_id != "" ? [1] : [] + content { + test = "StringEquals" + variable = "sts:ExternalId" + values = [var.require_external_id] + } + } + } + } + + # Cross-account trust (specific roles) + dynamic "statement" { + for_each = var.role_type == "cross-account" && length(var.trusted_role_arns) > 0 ? [1] : [] + content { + effect = "Allow" + actions = ["sts:AssumeRole"] + principals { + type = "AWS" + identifiers = var.trusted_role_arns + } + } + } + + # OIDC trust + dynamic "statement" { + for_each = var.role_type == "oidc" && var.oidc_provider_arn != "" ? [1] : [] + content { + effect = "Allow" + actions = ["sts:AssumeRoleWithWebIdentity"] + principals { + type = "Federated" + identifiers = [var.oidc_provider_arn] + } + + dynamic "condition" { + for_each = length(var.oidc_subjects) > 0 ? [1] : [] + content { + test = "StringLike" + variable = "${replace(var.oidc_provider_arn, "/.*oidc-provider\\//", "")}:sub" + values = var.oidc_subjects + } + } + + condition { + test = "StringEquals" + variable = "${replace(var.oidc_provider_arn, "/.*oidc-provider\\//", "")}:aud" + values = var.oidc_audiences + } + } + } +} + +################################################################################ +# IAM Role +################################################################################ + +resource "aws_iam_role" "main" { + name = var.name + description = local.description + path = var.path + max_session_duration = var.max_session_duration + + assume_role_policy = data.aws_iam_policy_document.assume_role.json + permissions_boundary = var.permissions_boundary != "" ? var.permissions_boundary : null + + tags = merge(var.tags, { Name = var.name }) +} + +################################################################################ +# Managed Policies +################################################################################ + +resource "aws_iam_role_policy_attachment" "managed" { + for_each = toset(var.managed_policies) + role = aws_iam_role.main.name + policy_arn = each.value +} + +################################################################################ +# Inline Policies +################################################################################ + +resource "aws_iam_role_policy" "inline" { + for_each = var.inline_policies + name = each.key + role = aws_iam_role.main.id + policy = each.value +} + +################################################################################ +# Instance Profile +################################################################################ + +resource "aws_iam_instance_profile" "main" { + count = var.create_instance_profile ? 1 : 0 + name = var.name + role = aws_iam_role.main.name + + tags = merge(var.tags, { Name = var.name }) +} + +################################################################################ +# Outputs +################################################################################ + +output "role_arn" { + value = aws_iam_role.main.arn + description = "Role ARN" +} + +output "role_name" { + value = aws_iam_role.main.name + description = "Role name" +} + +output "role_id" { + value = aws_iam_role.main.unique_id + description = "Role unique ID" +} + +output "instance_profile_arn" { + value = var.create_instance_profile ? aws_iam_instance_profile.main[0].arn : null + description = "Instance profile ARN" +} + +output "instance_profile_name" { + value = var.create_instance_profile ? aws_iam_instance_profile.main[0].name : null + description = "Instance profile name" +} + +output "assume_role_command" { + value = var.role_type == "cross-account" ? "aws sts assume-role --role-arn ${aws_iam_role.main.arn} --role-session-name my-session" : null + description = "CLI command to assume the role" +} diff --git a/terraform/modules/identity-center/README.md b/terraform/modules/identity-center/README.md new file mode 100644 index 0000000..ff241e7 --- /dev/null +++ b/terraform/modules/identity-center/README.md @@ -0,0 +1,40 @@ +# identity-center + +Terraform module for AWS landing zone pattern. + +Configure AWS IAM Identity Center (formerly AWS SSO). + +## Planned Features + +- [ ] Default permission sets (Admin, PowerUser, ReadOnly, Billing) +- [ ] Custom permission sets with managed + inline policies +- [ ] Group-to-account assignments +- [ ] SCIM provisioning setup +- [ ] MFA enforcement +- [ ] Session duration policies + +## Planned Usage + +```hcl +module "identity_center" { + source = "../modules/identity-center" + + default_permission_sets = true + + permission_sets = { + DatabaseAdmin = { + description = "Database administration access" + session_duration = "PT8H" + managed_policies = ["arn:aws:iam::aws:policy/AmazonRDSFullAccess"] + } + } + + group_assignments = { + admins_prod = { + group_name = "AWS-Admins" + permission_set = "AdministratorAccess" + account_ids = ["111111111111", "222222222222"] + } + } +} +``` diff --git a/terraform/modules/identity-center/main.tf b/terraform/modules/identity-center/main.tf new file mode 100644 index 0000000..2a35bbb --- /dev/null +++ b/terraform/modules/identity-center/main.tf @@ -0,0 +1,145 @@ +################################################################################ +# Identity Center Module +# +# Configures AWS IAM Identity Center (formerly AWS SSO): +# - Permission sets with managed and inline policies +# - Account assignments for groups +# - Default permission sets (Admin, PowerUser, ReadOnly, Billing) +################################################################################ + +terraform { + required_version = ">= 1.5.0" + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 5.0" + } + } +} + +data "aws_ssoadmin_instances" "this" {} + +locals { + instance_arn = tolist(data.aws_ssoadmin_instances.this.arns)[0] + identity_store_id = tolist(data.aws_ssoadmin_instances.this.identity_store_ids)[0] + + # Default permission sets + default_permission_sets = var.create_default_permission_sets ? { + AdministratorAccess = { + description = "Full administrator access" + session_duration = "PT4H" + managed_policies = ["arn:aws:iam::aws:policy/AdministratorAccess"] + inline_policy = "" + } + PowerUserAccess = { + description = "Power user access (no IAM)" + session_duration = "PT4H" + managed_policies = ["arn:aws:iam::aws:policy/PowerUserAccess"] + inline_policy = "" + } + ReadOnlyAccess = { + description = "Read-only access" + session_duration = "PT8H" + managed_policies = ["arn:aws:iam::aws:policy/ReadOnlyAccess"] + inline_policy = "" + } + Billing = { + description = "Billing access" + session_duration = "PT4H" + managed_policies = ["arn:aws:iam::aws:policy/job-function/Billing"] + inline_policy = "" + } + ViewOnlyAccess = { + description = "View-only access (no data access)" + session_duration = "PT8H" + managed_policies = ["arn:aws:iam::aws:policy/job-function/ViewOnlyAccess"] + inline_policy = "" + } + } : {} + + # Merge default and custom permission sets + all_permission_sets = merge(local.default_permission_sets, var.permission_sets) +} + +################################################################################ +# Permission Sets +################################################################################ + +resource "aws_ssoadmin_permission_set" "this" { + for_each = local.all_permission_sets + + instance_arn = local.instance_arn + name = each.key + description = each.value.description + session_duration = each.value.session_duration + + tags = merge(var.tags, { + Name = each.key + }) +} + +# Attach managed policies +resource "aws_ssoadmin_managed_policy_attachment" "this" { + for_each = { + for pair in flatten([ + for ps_name, ps in local.all_permission_sets : [ + for policy in ps.managed_policies : { + key = "${ps_name}-${replace(policy, "/.*//", "")}" + ps_name = ps_name + policy_arn = policy + } + ] + ]) : pair.key => pair + } + + instance_arn = local.instance_arn + permission_set_arn = aws_ssoadmin_permission_set.this[each.value.ps_name].arn + managed_policy_arn = each.value.policy_arn +} + +# Attach inline policies +resource "aws_ssoadmin_permission_set_inline_policy" "this" { + for_each = { + for name, ps in local.all_permission_sets : name => ps + if ps.inline_policy != "" + } + + instance_arn = local.instance_arn + permission_set_arn = aws_ssoadmin_permission_set.this[each.key].arn + inline_policy = each.value.inline_policy +} + +################################################################################ +# Account Assignments +################################################################################ + +# Look up groups from Identity Store +data "aws_identitystore_group" "this" { + for_each = toset([for a in var.account_assignments : a.group_name]) + + identity_store_id = local.identity_store_id + + alternate_identifier { + unique_attribute { + attribute_path = "DisplayName" + attribute_value = each.value + } + } +} + +# Create account assignments +resource "aws_ssoadmin_account_assignment" "this" { + for_each = { + for a in var.account_assignments : + "${a.group_name}-${a.permission_set}-${a.account_id}" => a + } + + instance_arn = local.instance_arn + permission_set_arn = aws_ssoadmin_permission_set.this[each.value.permission_set].arn + + principal_id = data.aws_identitystore_group.this[each.value.group_name].group_id + principal_type = "GROUP" + + target_id = each.value.account_id + target_type = "AWS_ACCOUNT" +} diff --git a/terraform/modules/identity-center/outputs.tf b/terraform/modules/identity-center/outputs.tf new file mode 100644 index 0000000..69f81a7 --- /dev/null +++ b/terraform/modules/identity-center/outputs.tf @@ -0,0 +1,28 @@ +################################################################################ +# Identity Center - Outputs +################################################################################ + +output "instance_arn" { + value = local.instance_arn + description = "Identity Center instance ARN" +} + +output "identity_store_id" { + value = local.identity_store_id + description = "Identity Store ID" +} + +output "permission_set_arns" { + value = { for k, v in aws_ssoadmin_permission_set.this : k => v.arn } + description = "Map of permission set names to ARNs" +} + +output "sso_start_url" { + value = "https://${local.identity_store_id}.awsapps.com/start" + description = "SSO portal start URL" +} + +output "assignment_count" { + value = length(aws_ssoadmin_account_assignment.this) + description = "Number of account assignments created" +} diff --git a/terraform/modules/identity-center/variables.tf b/terraform/modules/identity-center/variables.tf new file mode 100644 index 0000000..9dd63ce --- /dev/null +++ b/terraform/modules/identity-center/variables.tf @@ -0,0 +1,36 @@ +################################################################################ +# Identity Center - Input Variables +################################################################################ + +variable "create_default_permission_sets" { + type = bool + default = true + description = "Create default permission sets (Admin, PowerUser, ReadOnly, Billing)" +} + +variable "permission_sets" { + type = map(object({ + description = string + session_duration = optional(string, "PT4H") + managed_policies = optional(list(string), []) + inline_policy = optional(string, "") + })) + default = {} + description = "Custom permission sets to create" +} + +variable "account_assignments" { + type = list(object({ + group_name = string + permission_set = string + account_id = string + })) + default = [] + description = "Group to account/permission assignments" +} + +variable "tags" { + type = map(string) + default = {} + description = "Tags to apply to resources" +} diff --git a/terraform/modules/kms-key/README.md b/terraform/modules/kms-key/README.md new file mode 100644 index 0000000..3e20f13 --- /dev/null +++ b/terraform/modules/kms-key/README.md @@ -0,0 +1,54 @@ +# kms-key + +KMS Key Module + +## Usage + +```hcl +module "kms_key" { + source = "../modules/kms-key" + + # Required variables + name = "" + + # Optional: see variables.tf for all options +} +``` + +## Requirements + +| Name | Version | +|------|---------| +| terraform | >= 1.5.0 | +| aws | >= 5.0 | + +## Inputs + +| Name | Description | Type | Required | +|------|-------------|------|----------| +| name | Key name (used for alias) | `string` | yes | +| description | Key description | `string` | no | +| deletion_window_in_days | Waiting period before key deletion (7-30 days) | `number` | no | +| enable_key_rotation | Enable automatic key rotation (annual) | `bool` | no | +| multi_region | Create a multi-region key | `bool` | no | +| key_usage | Key usage: ENCRYPT_DECRYPT or SIGN_VERIFY | `string` | no | +| key_spec | Key spec (SYMMETRIC_DEFAULT, RSA_2048, ECC_NIST_P256, etc.) | `string` | no | +| admin_principals | IAM ARNs with full admin access to the key | `list(string)` | no | +| user_principals | IAM ARNs with encrypt/decrypt access | `list(string)` | no | +| service_principals | AWS service principals that can use the key (e.g., logs.amaz... | `list(string)` | no | +| grant_accounts | Account IDs with cross-account access | `list(string)` | no | +| tags | | `map(string)` | no | + +## Outputs + +| Name | Description | +|------|-------------| +| key_id | KMS key ID | +| key_arn | KMS key ARN | +| alias_arn | KMS alias ARN | +| alias_name | KMS alias name | +| key_policy | Key policy document | + +## License + +Apache 2.0 - See LICENSE for details. diff --git a/terraform/modules/kms-key/main.tf b/terraform/modules/kms-key/main.tf new file mode 100644 index 0000000..de0c26c --- /dev/null +++ b/terraform/modules/kms-key/main.tf @@ -0,0 +1,290 @@ +################################################################################ +# KMS Key Module +# +# Customer-managed KMS keys for encryption: +# - Automatic key rotation +# - Cross-account access +# - Service-specific grants +# - Alias management +# - Key policies +# +# Usage: +# module "kms" { +# source = "../modules/kms-key" +# +# name = "myapp-encryption" +# description = "Encryption key for myapp" +# +# service_principals = ["logs.amazonaws.com", "s3.amazonaws.com"] +# } +################################################################################ + +terraform { + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 5.0" + } + } +} + +variable "name" { + type = string + description = "Key name (used for alias)" +} + +variable "description" { + type = string + default = "" + description = "Key description" +} + +variable "deletion_window_in_days" { + type = number + default = 30 + description = "Waiting period before key deletion (7-30 days)" + + validation { + condition = var.deletion_window_in_days >= 7 && var.deletion_window_in_days <= 30 + error_message = "Must be between 7 and 30 days" + } +} + +variable "enable_key_rotation" { + type = bool + default = true + description = "Enable automatic key rotation (annual)" +} + +variable "multi_region" { + type = bool + default = false + description = "Create a multi-region key" +} + +variable "key_usage" { + type = string + default = "ENCRYPT_DECRYPT" + description = "Key usage: ENCRYPT_DECRYPT or SIGN_VERIFY" + + validation { + condition = contains(["ENCRYPT_DECRYPT", "SIGN_VERIFY", "GENERATE_VERIFY_MAC"], var.key_usage) + error_message = "Must be ENCRYPT_DECRYPT, SIGN_VERIFY, or GENERATE_VERIFY_MAC" + } +} + +variable "key_spec" { + type = string + default = "SYMMETRIC_DEFAULT" + description = "Key spec (SYMMETRIC_DEFAULT, RSA_2048, ECC_NIST_P256, etc.)" +} + +variable "admin_principals" { + type = list(string) + default = [] + description = "IAM ARNs with full admin access to the key" +} + +variable "user_principals" { + type = list(string) + default = [] + description = "IAM ARNs with encrypt/decrypt access" +} + +variable "service_principals" { + type = list(string) + default = [] + description = "AWS service principals that can use the key (e.g., logs.amazonaws.com)" +} + +variable "grant_accounts" { + type = list(string) + default = [] + description = "Account IDs with cross-account access" +} + +variable "tags" { + type = map(string) + default = {} +} + +################################################################################ +# Data Sources +################################################################################ + +data "aws_caller_identity" "current" {} +data "aws_region" "current" {} + +################################################################################ +# KMS Key +################################################################################ + +resource "aws_kms_key" "main" { + description = var.description != "" ? var.description : "KMS key for ${var.name}" + deletion_window_in_days = var.deletion_window_in_days + enable_key_rotation = var.key_spec == "SYMMETRIC_DEFAULT" ? var.enable_key_rotation : false + multi_region = var.multi_region + key_usage = var.key_usage + customer_master_key_spec = var.key_spec + + policy = jsonencode({ + Version = "2012-10-17" + Statement = concat( + # Root account access (required) + [{ + Sid = "EnableRootPermissions" + Effect = "Allow" + Principal = { + AWS = "arn:aws:iam::${data.aws_caller_identity.current.account_id}:root" + } + Action = "kms:*" + Resource = "*" + }], + + # Admin principals + length(var.admin_principals) > 0 ? [{ + Sid = "KeyAdministrators" + Effect = "Allow" + Principal = { + AWS = var.admin_principals + } + Action = [ + "kms:Create*", + "kms:Describe*", + "kms:Enable*", + "kms:List*", + "kms:Put*", + "kms:Update*", + "kms:Revoke*", + "kms:Disable*", + "kms:Get*", + "kms:Delete*", + "kms:TagResource", + "kms:UntagResource", + "kms:ScheduleKeyDeletion", + "kms:CancelKeyDeletion" + ] + Resource = "*" + }] : [], + + # User principals + length(var.user_principals) > 0 ? [{ + Sid = "KeyUsers" + Effect = "Allow" + Principal = { + AWS = var.user_principals + } + Action = [ + "kms:Encrypt", + "kms:Decrypt", + "kms:ReEncrypt*", + "kms:GenerateDataKey*", + "kms:DescribeKey" + ] + Resource = "*" + }] : [], + + # Service principals + length(var.service_principals) > 0 ? [{ + Sid = "AllowServices" + Effect = "Allow" + Principal = { + Service = var.service_principals + } + Action = [ + "kms:Encrypt", + "kms:Decrypt", + "kms:ReEncrypt*", + "kms:GenerateDataKey*", + "kms:DescribeKey" + ] + Resource = "*" + Condition = { + StringEquals = { + "aws:SourceAccount" = data.aws_caller_identity.current.account_id + } + } + }] : [], + + # Cross-account access + length(var.grant_accounts) > 0 ? [{ + Sid = "CrossAccountAccess" + Effect = "Allow" + Principal = { + AWS = [for acct in var.grant_accounts : "arn:aws:iam::${acct}:root"] + } + Action = [ + "kms:Encrypt", + "kms:Decrypt", + "kms:ReEncrypt*", + "kms:GenerateDataKey*", + "kms:DescribeKey" + ] + Resource = "*" + }] : [], + + # Allow grants (needed for some AWS services) + [{ + Sid = "AllowGrants" + Effect = "Allow" + Principal = { + AWS = concat( + ["arn:aws:iam::${data.aws_caller_identity.current.account_id}:root"], + var.user_principals + ) + } + Action = [ + "kms:CreateGrant", + "kms:ListGrants", + "kms:RevokeGrant" + ] + Resource = "*" + Condition = { + Bool = { + "kms:GrantIsForAWSResource" = "true" + } + } + }] + ) + }) + + tags = merge(var.tags, { Name = var.name }) +} + +################################################################################ +# Alias +################################################################################ + +resource "aws_kms_alias" "main" { + name = "alias/${var.name}" + target_key_id = aws_kms_key.main.key_id +} + +################################################################################ +# Outputs +################################################################################ + +output "key_id" { + value = aws_kms_key.main.key_id + description = "KMS key ID" +} + +output "key_arn" { + value = aws_kms_key.main.arn + description = "KMS key ARN" +} + +output "alias_arn" { + value = aws_kms_alias.main.arn + description = "KMS alias ARN" +} + +output "alias_name" { + value = aws_kms_alias.main.name + description = "KMS alias name" +} + +output "key_policy" { + value = aws_kms_key.main.policy + description = "Key policy document" +} diff --git a/terraform/modules/lambda-function/README.md b/terraform/modules/lambda-function/README.md new file mode 100644 index 0000000..d04b9e8 --- /dev/null +++ b/terraform/modules/lambda-function/README.md @@ -0,0 +1,65 @@ +# lambda-function + +Lambda Function Module + +## Usage + +```hcl +module "lambda_function" { + source = "../modules/lambda-function" + + # Required variables + name = "" + vpc_config = "" + function_url = "" + + # Optional: see variables.tf for all options +} +``` + +## Requirements + +| Name | Version | +|------|---------| +| terraform | >= 1.5.0 | +| aws | >= 5.0 | + +## Inputs + +| Name | Description | Type | Required | +|------|-------------|------|----------| +| name | Function name | `string` | yes | +| description | Function description | `string` | no | +| runtime | Lambda runtime | `string` | no | +| handler | Function handler | `string` | no | +| architectures | CPU architecture (arm64 or x86_64) | `list(string)` | no | +| memory_size | Memory in MB (128-10240) | `number` | no | +| timeout | Timeout in seconds (max 900) | `number` | no | +| reserved_concurrent_executions | Reserved concurrency (-1 = unreserved) | `number` | no | +| source_dir | Local source directory to zip | `string` | no | +| source_file | Single source file to deploy | `string` | no | +| s3_bucket | S3 bucket containing deployment package | `string` | no | +| s3_key | S3 key for deployment package | `string` | no | +| image_uri | Container image URI | `string` | no | +| vpc_config | | `object({` | yes | +| environment | | `map(string)` | no | + +*...and 12 more variables. See `variables.tf` for complete list.* + +## Outputs + +| Name | Description | +|------|-------------| +| function_name | Function name | +| function_arn | Function ARN | +| invoke_arn | Invoke ARN (for API Gateway) | +| qualified_arn | Qualified ARN (includes version) | +| role_arn | IAM role ARN | +| role_name | IAM role name | +| log_group_name | CloudWatch log group name | +| function_url | Function URL | +| version | Published version | + +## License + +Apache 2.0 - See LICENSE for details. diff --git a/terraform/modules/lambda-function/main.tf b/terraform/modules/lambda-function/main.tf new file mode 100644 index 0000000..df0219c --- /dev/null +++ b/terraform/modules/lambda-function/main.tf @@ -0,0 +1,501 @@ +################################################################################ +# Lambda Function Module +# +# Reusable Lambda deployment with: +# - S3 or local zip deployment +# - VPC access (optional) +# - Environment variables +# - Secrets Manager integration +# - CloudWatch logs +# - X-Ray tracing +# - Provisioned concurrency +# - Function URL (optional) +# +# Usage: +# module "api_lambda" { +# source = "../modules/lambda-function" +# +# name = "my-api" +# runtime = "nodejs20.x" +# handler = "index.handler" +# +# source_dir = "${path.module}/src" +# +# environment = { +# LOG_LEVEL = "info" +# } +# } +################################################################################ + +terraform { + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 5.0" + } + archive = { + source = "hashicorp/archive" + version = ">= 2.0" + } + } +} + +variable "name" { + type = string + description = "Function name" +} + +variable "description" { + type = string + default = "" + description = "Function description" +} + +variable "runtime" { + type = string + default = "nodejs20.x" + description = "Lambda runtime" +} + +variable "handler" { + type = string + default = "index.handler" + description = "Function handler" +} + +variable "architectures" { + type = list(string) + default = ["arm64"] + description = "CPU architecture (arm64 or x86_64)" +} + +variable "memory_size" { + type = number + default = 256 + description = "Memory in MB (128-10240)" +} + +variable "timeout" { + type = number + default = 30 + description = "Timeout in seconds (max 900)" +} + +variable "reserved_concurrent_executions" { + type = number + default = -1 + description = "Reserved concurrency (-1 = unreserved)" +} + +# Deployment options +variable "source_dir" { + type = string + default = "" + description = "Local source directory to zip" +} + +variable "source_file" { + type = string + default = "" + description = "Single source file to deploy" +} + +variable "s3_bucket" { + type = string + default = "" + description = "S3 bucket containing deployment package" +} + +variable "s3_key" { + type = string + default = "" + description = "S3 key for deployment package" +} + +variable "image_uri" { + type = string + default = "" + description = "Container image URI" +} + +# VPC configuration +variable "vpc_config" { + type = object({ + subnet_ids = list(string) + security_group_ids = list(string) + }) + default = null + description = "VPC configuration for Lambda" +} + +# Environment +variable "environment" { + type = map(string) + default = {} + description = "Environment variables" +} + +variable "secrets" { + type = map(string) + default = {} + description = "Secrets Manager ARNs (name -> ARN)" +} + +variable "ssm_parameters" { + type = map(string) + default = {} + description = "SSM parameter ARNs (name -> ARN)" +} + +# Layers +variable "layers" { + type = list(string) + default = [] + description = "Lambda layer ARNs" +} + +# Tracing +variable "tracing_mode" { + type = string + default = "Active" + description = "X-Ray tracing mode (Active, PassThrough, or empty)" +} + +# Logging +variable "log_retention_days" { + type = number + default = 14 + description = "CloudWatch log retention in days" +} + +variable "log_format" { + type = string + default = "Text" + description = "Log format: Text or JSON" +} + +# Function URL +variable "function_url" { + type = object({ + enabled = bool + auth_type = optional(string, "NONE") + cors_origins = optional(list(string), ["*"]) + cors_methods = optional(list(string), ["*"]) + cors_headers = optional(list(string), ["*"]) + invoke_mode = optional(string, "BUFFERED") + }) + default = { + enabled = false + } + description = "Lambda function URL configuration" +} + +# Provisioned concurrency +variable "provisioned_concurrency" { + type = number + default = 0 + description = "Provisioned concurrency (0 = disabled)" +} + +# Additional IAM policies +variable "policy_arns" { + type = list(string) + default = [] + description = "Additional IAM policy ARNs to attach" +} + +variable "inline_policy" { + type = string + default = "" + description = "Inline IAM policy JSON" +} + +# Dead letter queue +variable "dead_letter_arn" { + type = string + default = "" + description = "SQS queue or SNS topic ARN for failed invocations" +} + +variable "tags" { + type = map(string) + default = {} +} + +################################################################################ +# Data Sources +################################################################################ + +data "aws_caller_identity" "current" {} +data "aws_region" "current" {} + +################################################################################ +# Archive (if using source_dir) +################################################################################ + +data "archive_file" "lambda" { + count = var.source_dir != "" ? 1 : (var.source_file != "" ? 1 : 0) + + type = "zip" + output_path = "${path.module}/.terraform/${var.name}.zip" + + source_dir = var.source_dir != "" ? var.source_dir : null + source_file = var.source_file != "" ? var.source_file : null +} + +################################################################################ +# IAM Role +################################################################################ + +resource "aws_iam_role" "lambda" { + name = "${var.name}-lambda" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Effect = "Allow" + Action = "sts:AssumeRole" + Principal = { Service = "lambda.amazonaws.com" } + }] + }) + + tags = merge(var.tags, { Name = "${var.name}-lambda" }) +} + +# Basic execution role +resource "aws_iam_role_policy_attachment" "basic" { + role = aws_iam_role.lambda.name + policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" +} + +# VPC access +resource "aws_iam_role_policy_attachment" "vpc" { + count = var.vpc_config != null ? 1 : 0 + role = aws_iam_role.lambda.name + policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaVPCAccessExecutionRole" +} + +# X-Ray +resource "aws_iam_role_policy_attachment" "xray" { + count = var.tracing_mode != "" ? 1 : 0 + role = aws_iam_role.lambda.name + policy_arn = "arn:aws:iam::aws:policy/AWSXRayDaemonWriteAccess" +} + +# Secrets Manager access +resource "aws_iam_role_policy" "secrets" { + count = length(var.secrets) > 0 ? 1 : 0 + name = "secrets-access" + role = aws_iam_role.lambda.id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Effect = "Allow" + Action = "secretsmanager:GetSecretValue" + Resource = values(var.secrets) + }] + }) +} + +# SSM Parameter access +resource "aws_iam_role_policy" "ssm" { + count = length(var.ssm_parameters) > 0 ? 1 : 0 + name = "ssm-access" + role = aws_iam_role.lambda.id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Effect = "Allow" + Action = ["ssm:GetParameter", "ssm:GetParameters"] + Resource = values(var.ssm_parameters) + }] + }) +} + +# Additional policies +resource "aws_iam_role_policy_attachment" "additional" { + for_each = toset(var.policy_arns) + role = aws_iam_role.lambda.name + policy_arn = each.value +} + +# Inline policy +resource "aws_iam_role_policy" "inline" { + count = var.inline_policy != "" ? 1 : 0 + name = "inline" + role = aws_iam_role.lambda.id + policy = var.inline_policy +} + +################################################################################ +# CloudWatch Log Group +################################################################################ + +resource "aws_cloudwatch_log_group" "lambda" { + name = "/aws/lambda/${var.name}" + retention_in_days = var.log_retention_days + + tags = merge(var.tags, { Name = var.name }) +} + +################################################################################ +# Lambda Function +################################################################################ + +resource "aws_lambda_function" "main" { + function_name = var.name + description = var.description != "" ? var.description : "Lambda function ${var.name}" + role = aws_iam_role.lambda.arn + + # Deployment package + filename = var.source_dir != "" || var.source_file != "" ? data.archive_file.lambda[0].output_path : null + source_code_hash = var.source_dir != "" || var.source_file != "" ? data.archive_file.lambda[0].output_base64sha256 : null + s3_bucket = var.s3_bucket != "" ? var.s3_bucket : null + s3_key = var.s3_key != "" ? var.s3_key : null + image_uri = var.image_uri != "" ? var.image_uri : null + package_type = var.image_uri != "" ? "Image" : "Zip" + + # Runtime config (not for container images) + runtime = var.image_uri == "" ? var.runtime : null + handler = var.image_uri == "" ? var.handler : null + architectures = var.architectures + layers = var.image_uri == "" ? var.layers : null + + # Resources + memory_size = var.memory_size + timeout = var.timeout + reserved_concurrent_executions = var.reserved_concurrent_executions + + # Environment + dynamic "environment" { + for_each = length(var.environment) > 0 ? [1] : [] + content { + variables = var.environment + } + } + + # VPC + dynamic "vpc_config" { + for_each = var.vpc_config != null ? [var.vpc_config] : [] + content { + subnet_ids = vpc_config.value.subnet_ids + security_group_ids = vpc_config.value.security_group_ids + } + } + + # Tracing + dynamic "tracing_config" { + for_each = var.tracing_mode != "" ? [1] : [] + content { + mode = var.tracing_mode + } + } + + # Dead letter queue + dynamic "dead_letter_config" { + for_each = var.dead_letter_arn != "" ? [1] : [] + content { + target_arn = var.dead_letter_arn + } + } + + # Logging + logging_config { + log_format = var.log_format + log_group = aws_cloudwatch_log_group.lambda.name + } + + tags = merge(var.tags, { Name = var.name }) + + depends_on = [aws_cloudwatch_log_group.lambda] +} + +################################################################################ +# Function URL +################################################################################ + +resource "aws_lambda_function_url" "main" { + count = var.function_url.enabled ? 1 : 0 + + function_name = aws_lambda_function.main.function_name + authorization_type = var.function_url.auth_type + invoke_mode = var.function_url.invoke_mode + + cors { + allow_origins = var.function_url.cors_origins + allow_methods = var.function_url.cors_methods + allow_headers = var.function_url.cors_headers + max_age = 86400 + } +} + +################################################################################ +# Provisioned Concurrency +################################################################################ + +resource "aws_lambda_alias" "live" { + count = var.provisioned_concurrency > 0 ? 1 : 0 + + name = "live" + function_name = aws_lambda_function.main.function_name + function_version = aws_lambda_function.main.version +} + +resource "aws_lambda_provisioned_concurrency_config" "main" { + count = var.provisioned_concurrency > 0 ? 1 : 0 + + function_name = aws_lambda_function.main.function_name + provisioned_concurrent_executions = var.provisioned_concurrency + qualifier = aws_lambda_alias.live[0].name +} + +################################################################################ +# Outputs +################################################################################ + +output "function_name" { + value = aws_lambda_function.main.function_name + description = "Function name" +} + +output "function_arn" { + value = aws_lambda_function.main.arn + description = "Function ARN" +} + +output "invoke_arn" { + value = aws_lambda_function.main.invoke_arn + description = "Invoke ARN (for API Gateway)" +} + +output "qualified_arn" { + value = aws_lambda_function.main.qualified_arn + description = "Qualified ARN (includes version)" +} + +output "role_arn" { + value = aws_iam_role.lambda.arn + description = "IAM role ARN" +} + +output "role_name" { + value = aws_iam_role.lambda.name + description = "IAM role name" +} + +output "log_group_name" { + value = aws_cloudwatch_log_group.lambda.name + description = "CloudWatch log group name" +} + +output "function_url" { + value = var.function_url.enabled ? aws_lambda_function_url.main[0].function_url : null + description = "Function URL" +} + +output "version" { + value = aws_lambda_function.main.version + description = "Published version" +} diff --git a/terraform/modules/ram-share/README.md b/terraform/modules/ram-share/README.md new file mode 100644 index 0000000..2228030 --- /dev/null +++ b/terraform/modules/ram-share/README.md @@ -0,0 +1,34 @@ +# ram-share + +Terraform module for AWS landing zone pattern. + +Share resources across accounts via AWS Resource Access Manager. + +## Planned Features + +- [ ] VPC subnet sharing +- [ ] Transit Gateway sharing +- [ ] Route53 Resolver rule sharing +- [ ] Organization-wide sharing option +- [ ] OU-level sharing + +## Planned Usage + +```hcl +module "vpc_share" { + source = "../modules/ram-share" + + name = "shared-vpc-subnets" + + resources = [ + aws_subnet.private_a.arn, + aws_subnet.private_b.arn, + ] + + # Share with specific accounts + principals = ["111111111111", "222222222222"] + + # Or share with entire org + # allow_organization = true +} +``` diff --git a/terraform/modules/ram-share/main.tf b/terraform/modules/ram-share/main.tf new file mode 100644 index 0000000..f30d3f6 --- /dev/null +++ b/terraform/modules/ram-share/main.tf @@ -0,0 +1,83 @@ +################################################################################ +# RAM Share Module +# +# Shares resources across accounts via AWS Resource Access Manager: +# - VPC subnets +# - Transit Gateway +# - Route53 Resolver rules +# - Any RAM-supported resource +################################################################################ + +terraform { + required_version = ">= 1.5.0" + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 5.0" + } + } +} + +data "aws_organizations_organization" "this" { + count = var.share_with_organization ? 1 : 0 +} + +locals { + # Organization ARN for org-wide sharing + org_arn = var.share_with_organization ? data.aws_organizations_organization.this[0].arn : null +} + +################################################################################ +# Resource Share +################################################################################ + +resource "aws_ram_resource_share" "this" { + name = var.name + allow_external_principals = var.allow_external_principals + + # Enable org sharing if specified + permission_arns = var.permission_arns + + tags = merge(var.tags, { + Name = var.name + }) +} + +################################################################################ +# Resource Associations +################################################################################ + +resource "aws_ram_resource_association" "this" { + for_each = toset(var.resource_arns) + + resource_arn = each.value + resource_share_arn = aws_ram_resource_share.this.arn +} + +################################################################################ +# Principal Associations +################################################################################ + +# Share with organization +resource "aws_ram_principal_association" "organization" { + count = var.share_with_organization ? 1 : 0 + + principal = local.org_arn + resource_share_arn = aws_ram_resource_share.this.arn +} + +# Share with specific OUs +resource "aws_ram_principal_association" "ous" { + for_each = toset(var.principal_ous) + + principal = each.value + resource_share_arn = aws_ram_resource_share.this.arn +} + +# Share with specific accounts +resource "aws_ram_principal_association" "accounts" { + for_each = toset(var.principal_accounts) + + principal = each.value + resource_share_arn = aws_ram_resource_share.this.arn +} diff --git a/terraform/modules/ram-share/outputs.tf b/terraform/modules/ram-share/outputs.tf new file mode 100644 index 0000000..ece1c9f --- /dev/null +++ b/terraform/modules/ram-share/outputs.tf @@ -0,0 +1,27 @@ +################################################################################ +# RAM Share - Outputs +################################################################################ + +output "share_arn" { + value = aws_ram_resource_share.this.arn + description = "Resource share ARN" +} + +output "share_id" { + value = aws_ram_resource_share.this.id + description = "Resource share ID" +} + +output "resource_associations" { + value = { for k, v in aws_ram_resource_association.this : k => v.id } + description = "Map of resource associations" +} + +output "principal_count" { + value = ( + (var.share_with_organization ? 1 : 0) + + length(var.principal_ous) + + length(var.principal_accounts) + ) + description = "Number of principals shared with" +} diff --git a/terraform/modules/ram-share/variables.tf b/terraform/modules/ram-share/variables.tf new file mode 100644 index 0000000..ce0ed30 --- /dev/null +++ b/terraform/modules/ram-share/variables.tf @@ -0,0 +1,49 @@ +################################################################################ +# RAM Share - Input Variables +################################################################################ + +variable "name" { + type = string + description = "Name of the resource share" +} + +variable "resource_arns" { + type = list(string) + description = "List of resource ARNs to share" +} + +variable "share_with_organization" { + type = bool + default = false + description = "Share with entire organization" +} + +variable "principal_ous" { + type = list(string) + default = [] + description = "OU ARNs to share with" +} + +variable "principal_accounts" { + type = list(string) + default = [] + description = "Account IDs to share with" +} + +variable "allow_external_principals" { + type = bool + default = false + description = "Allow sharing with external accounts" +} + +variable "permission_arns" { + type = list(string) + default = null + description = "Custom RAM permission ARNs" +} + +variable "tags" { + type = map(string) + default = {} + description = "Tags to apply to resources" +} diff --git a/terraform/modules/route53-zone/README.md b/terraform/modules/route53-zone/README.md new file mode 100644 index 0000000..7f9a792 --- /dev/null +++ b/terraform/modules/route53-zone/README.md @@ -0,0 +1,57 @@ +# route53-zone + +Route53 Zone Module + +## Usage + +```hcl +module "route53_zone" { + source = "../modules/route53-zone" + + # Required variables + domain_name = "" + records = "" + alias_records = "" + mx_records = "" + + # Optional: see variables.tf for all options +} +``` + +## Requirements + +| Name | Version | +|------|---------| +| terraform | >= 1.5.0 | +| aws | >= 5.0 | + +## Inputs + +| Name | Description | Type | Required | +|------|-------------|------|----------| +| domain_name | Domain name for the hosted zone | `string` | yes | +| comment | Comment for the hosted zone | `string` | no | +| private_zone | Create a private hosted zone | `bool` | no | +| vpc_ids | VPC IDs to associate with private zone | `list(string)` | no | +| enable_dnssec | Enable DNSSEC signing | `bool` | no | +| enable_query_logging | Enable query logging to CloudWatch | `bool` | no | +| query_log_retention_days | Query log retention in days | `number` | no | +| records | | `map(object({` | yes | +| alias_records | | `map(object({` | yes | +| mx_records | | `list(object({` | yes | +| txt_records | | `map(string)` | no | +| tags | | `map(string)` | no | + +## Outputs + +| Name | Description | +|------|-------------| +| zone_id | Hosted zone ID | +| zone_arn | Hosted zone ARN | +| name_servers | Name servers for the zone (update at registrar) | +| domain_name | Domain name | +| dnssec_ds_record | DS record for DNSSEC (add to parent zone/registrar) | + +## License + +Apache 2.0 - See LICENSE for details. diff --git a/terraform/modules/route53-zone/main.tf b/terraform/modules/route53-zone/main.tf new file mode 100644 index 0000000..5670336 --- /dev/null +++ b/terraform/modules/route53-zone/main.tf @@ -0,0 +1,418 @@ +################################################################################ +# Route53 Zone Module +# +# DNS zone management: +# - Public or private hosted zones +# - Common record types (A, AAAA, CNAME, MX, TXT) +# - Alias records (CloudFront, ALB, S3, API Gateway) +# - DNSSEC signing +# - Query logging +# - Health checks +# +# Usage: +# module "dns" { +# source = "../modules/route53-zone" +# +# domain_name = "example.com" +# +# records = { +# "www" = { +# type = "CNAME" +# ttl = 300 +# records = ["example.com"] +# } +# "mail" = { +# type = "MX" +# ttl = 300 +# records = ["10 mail.example.com"] +# } +# } +# } +################################################################################ + +terraform { + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 5.0" + } + } +} + +variable "domain_name" { + type = string + description = "Domain name for the hosted zone" +} + +variable "comment" { + type = string + default = "" + description = "Comment for the hosted zone" +} + +variable "private_zone" { + type = bool + default = false + description = "Create a private hosted zone" +} + +variable "vpc_ids" { + type = list(string) + default = [] + description = "VPC IDs to associate with private zone" +} + +variable "enable_dnssec" { + type = bool + default = false + description = "Enable DNSSEC signing" +} + +variable "enable_query_logging" { + type = bool + default = false + description = "Enable query logging to CloudWatch" +} + +variable "query_log_retention_days" { + type = number + default = 30 + description = "Query log retention in days" +} + +variable "records" { + type = map(object({ + type = string + ttl = optional(number, 300) + records = optional(list(string)) + alias = optional(object({ + name = string + zone_id = string + evaluate_target_health = optional(bool, false) + })) + health_check_id = optional(string) + set_identifier = optional(string) + weight = optional(number) + latency_routing_region = optional(string) + geolocation = optional(object({ + continent = optional(string) + country = optional(string) + subdivision = optional(string) + })) + failover = optional(string) + })) + default = {} + description = "DNS records to create" +} + +variable "alias_records" { + type = map(object({ + type = optional(string, "A") + target_dns_name = string + target_zone_id = string + evaluate_target_health = optional(bool, false) + })) + default = {} + description = "Alias records (simplified syntax for CloudFront, ALB, etc.)" +} + +variable "mx_records" { + type = list(object({ + priority = number + server = string + })) + default = [] + description = "MX records for email" +} + +variable "txt_records" { + type = map(string) + default = {} + description = "TXT records (name -> value)" +} + +variable "tags" { + type = map(string) + default = {} +} + +data "aws_region" "current" {} + +################################################################################ +# Hosted Zone +################################################################################ + +resource "aws_route53_zone" "main" { + name = var.domain_name + comment = var.comment != "" ? var.comment : "Managed by Terraform" + + dynamic "vpc" { + for_each = var.private_zone ? var.vpc_ids : [] + content { + vpc_id = vpc.value + } + } + + tags = merge(var.tags, { Name = var.domain_name }) +} + +################################################################################ +# Standard Records +################################################################################ + +resource "aws_route53_record" "records" { + for_each = var.records + + zone_id = aws_route53_zone.main.zone_id + name = each.key == "@" ? var.domain_name : "${each.key}.${var.domain_name}" + type = each.value.type + + # Standard records + ttl = each.value.alias == null ? each.value.ttl : null + records = each.value.alias == null ? each.value.records : null + + # Alias records + dynamic "alias" { + for_each = each.value.alias != null ? [each.value.alias] : [] + content { + name = alias.value.name + zone_id = alias.value.zone_id + evaluate_target_health = alias.value.evaluate_target_health + } + } + + # Routing policies + health_check_id = each.value.health_check_id + set_identifier = each.value.set_identifier + + dynamic "weighted_routing_policy" { + for_each = each.value.weight != null ? [1] : [] + content { + weight = each.value.weight + } + } + + dynamic "latency_routing_policy" { + for_each = each.value.latency_routing_region != null ? [1] : [] + content { + region = each.value.latency_routing_region + } + } + + dynamic "geolocation_routing_policy" { + for_each = each.value.geolocation != null ? [each.value.geolocation] : [] + content { + continent = geolocation_routing_policy.value.continent + country = geolocation_routing_policy.value.country + subdivision = geolocation_routing_policy.value.subdivision + } + } + + dynamic "failover_routing_policy" { + for_each = each.value.failover != null ? [1] : [] + content { + type = each.value.failover + } + } +} + +################################################################################ +# Simplified Alias Records +################################################################################ + +resource "aws_route53_record" "alias" { + for_each = var.alias_records + + zone_id = aws_route53_zone.main.zone_id + name = each.key == "@" ? var.domain_name : "${each.key}.${var.domain_name}" + type = each.value.type + + alias { + name = each.value.target_dns_name + zone_id = each.value.target_zone_id + evaluate_target_health = each.value.evaluate_target_health + } +} + +################################################################################ +# MX Records +################################################################################ + +resource "aws_route53_record" "mx" { + count = length(var.mx_records) > 0 ? 1 : 0 + + zone_id = aws_route53_zone.main.zone_id + name = var.domain_name + type = "MX" + ttl = 300 + + records = [for mx in var.mx_records : "${mx.priority} ${mx.server}"] +} + +################################################################################ +# TXT Records +################################################################################ + +resource "aws_route53_record" "txt" { + for_each = var.txt_records + + zone_id = aws_route53_zone.main.zone_id + name = each.key == "@" ? var.domain_name : "${each.key}.${var.domain_name}" + type = "TXT" + ttl = 300 + records = [each.value] +} + +################################################################################ +# DNSSEC +################################################################################ + +resource "aws_route53_key_signing_key" "main" { + count = var.enable_dnssec && !var.private_zone ? 1 : 0 + + hosted_zone_id = aws_route53_zone.main.id + key_management_service_arn = aws_kms_key.dnssec[0].arn + name = "${replace(var.domain_name, ".", "-")}-ksk" +} + +resource "aws_kms_key" "dnssec" { + count = var.enable_dnssec && !var.private_zone ? 1 : 0 + + customer_master_key_spec = "ECC_NIST_P256" + deletion_window_in_days = 7 + key_usage = "SIGN_VERIFY" + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Sid = "Enable IAM User Permissions" + Effect = "Allow" + Principal = { + AWS = "arn:aws:iam::${data.aws_caller_identity.current.account_id}:root" + } + Action = "kms:*" + Resource = "*" + }, + { + Sid = "Allow Route 53 DNSSEC Service" + Effect = "Allow" + Principal = { + Service = "dnssec-route53.amazonaws.com" + } + Action = [ + "kms:DescribeKey", + "kms:GetPublicKey", + "kms:Sign" + ] + Resource = "*" + Condition = { + StringEquals = { + "aws:SourceAccount" = data.aws_caller_identity.current.account_id + } + ArnLike = { + "aws:SourceArn" = "arn:aws:route53:::hostedzone/*" + } + } + }, + { + Sid = "Allow Route 53 DNSSEC to CreateGrant" + Effect = "Allow" + Principal = { + Service = "dnssec-route53.amazonaws.com" + } + Action = "kms:CreateGrant" + Resource = "*" + Condition = { + Bool = { + "kms:GrantIsForAWSResource" = "true" + } + } + } + ] + }) + + tags = merge(var.tags, { Name = "${var.domain_name}-dnssec" }) +} + +data "aws_caller_identity" "current" {} + +resource "aws_route53_hosted_zone_dnssec" "main" { + count = var.enable_dnssec && !var.private_zone ? 1 : 0 + + hosted_zone_id = aws_route53_zone.main.id + + depends_on = [aws_route53_key_signing_key.main] +} + +################################################################################ +# Query Logging +################################################################################ + +resource "aws_cloudwatch_log_group" "query_log" { + count = var.enable_query_logging && !var.private_zone ? 1 : 0 + + name = "/aws/route53/${var.domain_name}" + retention_in_days = var.query_log_retention_days + + tags = merge(var.tags, { Name = var.domain_name }) +} + +resource "aws_cloudwatch_log_resource_policy" "query_log" { + count = var.enable_query_logging && !var.private_zone ? 1 : 0 + + policy_name = "route53-query-logging-${replace(var.domain_name, ".", "-")}" + + policy_document = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Effect = "Allow" + Principal = { + Service = "route53.amazonaws.com" + } + Action = [ + "logs:CreateLogStream", + "logs:PutLogEvents" + ] + Resource = "${aws_cloudwatch_log_group.query_log[0].arn}:*" + }] + }) +} + +resource "aws_route53_query_log" "main" { + count = var.enable_query_logging && !var.private_zone ? 1 : 0 + + cloudwatch_log_group_arn = aws_cloudwatch_log_group.query_log[0].arn + zone_id = aws_route53_zone.main.zone_id + + depends_on = [aws_cloudwatch_log_resource_policy.query_log] +} + +################################################################################ +# Outputs +################################################################################ + +output "zone_id" { + value = aws_route53_zone.main.zone_id + description = "Hosted zone ID" +} + +output "zone_arn" { + value = aws_route53_zone.main.arn + description = "Hosted zone ARN" +} + +output "name_servers" { + value = aws_route53_zone.main.name_servers + description = "Name servers for the zone (update at registrar)" +} + +output "domain_name" { + value = var.domain_name + description = "Domain name" +} + +output "dnssec_ds_record" { + value = var.enable_dnssec && !var.private_zone ? aws_route53_key_signing_key.main[0].ds_record : null + description = "DS record for DNSSEC (add to parent zone/registrar)" +} diff --git a/terraform/modules/scps/README.md b/terraform/modules/scps/README.md new file mode 100644 index 0000000..a0107f2 --- /dev/null +++ b/terraform/modules/scps/README.md @@ -0,0 +1,97 @@ +# scps + +AWS Organizations Service Control Policies for security guardrails. + +## Features + +- **Deny leaving organization** - Prevent accounts from leaving +- **Require IMDSv2** - Block EC2 instances without IMDSv2 +- **Deny root actions** - Block most root user operations +- **Region restrictions** - Limit operations to allowed regions +- **Protect security services** - Prevent disabling GuardDuty, Security Hub, Config +- **Protect CloudTrail** - Prevent trail modification +- **Require S3 encryption** - Block unencrypted S3 objects +- **Require EBS encryption** - Block unencrypted volumes + +## Usage + +```hcl +module "scps" { + source = "../modules/scps" + + name_prefix = "org" + + # Enable all security guardrails + enable_deny_leave_org = true + enable_require_imdsv2 = true + enable_deny_root_actions = true + protect_security_services = true + protect_cloudtrail = true + require_s3_encryption = true + require_ebs_encryption = true + + # Optional: Region restriction + allowed_regions = ["us-east-1", "us-west-2", "eu-west-1"] + + # Attach to OUs + target_ous = [ + "ou-xxxx-workloads", + "ou-xxxx-sandbox" + ] + + tags = { + Environment = "org" + ManagedBy = "terraform" + } +} +``` + +## Requirements + +| Name | Version | +|------|---------| +| terraform | >= 1.5.0 | +| aws | >= 5.0 | + +## Providers + +Must be run from the **AWS Organizations management account**. + +## Inputs + +| Name | Description | Type | Default | Required | +|------|-------------|------|---------|----------| +| name_prefix | Prefix for SCP names | `string` | `"scp"` | no | +| enable_deny_leave_org | Prevent accounts from leaving | `bool` | `true` | no | +| enable_require_imdsv2 | Require IMDSv2 for EC2 | `bool` | `true` | no | +| enable_deny_root_actions | Deny root user actions | `bool` | `true` | no | +| allowed_regions | Allowed AWS regions | `list(string)` | `[]` | no | +| protect_security_services | Protect security services | `bool` | `true` | no | +| protect_cloudtrail | Protect CloudTrail | `bool` | `true` | no | +| require_s3_encryption | Require S3 encryption | `bool` | `true` | no | +| require_ebs_encryption | Require EBS encryption | `bool` | `true` | no | +| target_ous | OU IDs to attach SCPs | `list(string)` | `[]` | no | +| target_accounts | Account IDs to attach SCPs | `list(string)` | `[]` | no | +| tags | Resource tags | `map(string)` | `{}` | no | + +## Outputs + +| Name | Description | +|------|-------------| +| policy_ids | Map of SCP names to policy IDs | +| policy_arns | Map of SCP names to policy ARNs | +| enabled_policies | List of enabled SCP names | +| attachment_count | Count of attachments | + +## Security Best Practices + +These SCPs implement: +- CIS AWS Foundations Benchmark +- AWS Security Reference Architecture +- Well-Architected Framework Security Pillar + +## Notes + +- SCPs only affect member accounts, not the management account +- Test SCPs in sandbox OU before applying to production +- Global services (IAM, Route53, etc.) are exempt from region restrictions diff --git a/terraform/modules/scps/main.tf b/terraform/modules/scps/main.tf new file mode 100644 index 0000000..6ad5cc5 --- /dev/null +++ b/terraform/modules/scps/main.tf @@ -0,0 +1,388 @@ +################################################################################ +# Service Control Policies Module +# +# Implements AWS Organizations SCPs for security guardrails: +# - Deny leaving organization +# - Require IMDSv2 +# - Deny root user actions +# - Region restrictions +# - Protect security services +# - Protect CloudTrail +# - Require encryption +# +# References: +# - AWS SRA: https://docs.aws.amazon.com/prescriptive-guidance/latest/security-reference-architecture +# - CIS Benchmark: https://www.cisecurity.org/benchmark/amazon_web_services +################################################################################ + +terraform { + required_version = ">= 1.5.0" + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 5.0" + } + } +} + +locals { + # Build list of SCPs to create based on enabled flags + scps = merge( + var.enable_deny_leave_org ? { + deny_leave_org = { + name = "${var.name_prefix}-deny-leave-org" + description = "Prevent accounts from leaving the organization" + policy = data.aws_iam_policy_document.deny_leave_org.json + } + } : {}, + var.enable_require_imdsv2 ? { + require_imdsv2 = { + name = "${var.name_prefix}-require-imdsv2" + description = "Require IMDSv2 for EC2 instances" + policy = data.aws_iam_policy_document.require_imdsv2.json + } + } : {}, + var.enable_deny_root_actions ? { + deny_root = { + name = "${var.name_prefix}-deny-root-actions" + description = "Deny most actions by root user" + policy = data.aws_iam_policy_document.deny_root.json + } + } : {}, + length(var.allowed_regions) > 0 ? { + region_restriction = { + name = "${var.name_prefix}-region-restriction" + description = "Restrict operations to allowed regions" + policy = data.aws_iam_policy_document.region_restriction.json + } + } : {}, + var.protect_security_services ? { + protect_security = { + name = "${var.name_prefix}-protect-security-services" + description = "Prevent disabling security services" + policy = data.aws_iam_policy_document.protect_security.json + } + } : {}, + var.protect_cloudtrail ? { + protect_cloudtrail = { + name = "${var.name_prefix}-protect-cloudtrail" + description = "Prevent CloudTrail modification" + policy = data.aws_iam_policy_document.protect_cloudtrail.json + } + } : {}, + var.require_s3_encryption ? { + require_s3_encryption = { + name = "${var.name_prefix}-require-s3-encryption" + description = "Require S3 bucket encryption" + policy = data.aws_iam_policy_document.require_s3_encryption.json + } + } : {}, + var.require_ebs_encryption ? { + require_ebs_encryption = { + name = "${var.name_prefix}-require-ebs-encryption" + description = "Require EBS volume encryption" + policy = data.aws_iam_policy_document.require_ebs_encryption.json + } + } : {}, + ) + + # Global services that shouldn't be region-restricted + global_services = [ + "iam", + "organizations", + "sts", + "support", + "budgets", + "cloudfront", + "route53", + "waf", + "waf-regional", + "health", + "trustedadvisor", + ] +} + +################################################################################ +# Policy Documents +################################################################################ + +data "aws_iam_policy_document" "deny_leave_org" { + statement { + sid = "DenyLeaveOrganization" + effect = "Deny" + actions = ["organizations:LeaveOrganization"] + resources = ["*"] + } +} + +data "aws_iam_policy_document" "require_imdsv2" { + statement { + sid = "RequireIMDSv2" + effect = "Deny" + actions = [ + "ec2:RunInstances" + ] + resources = ["arn:aws:ec2:*:*:instance/*"] + + condition { + test = "StringNotEquals" + variable = "ec2:MetadataHttpTokens" + values = ["required"] + } + } + + statement { + sid = "DenyIMDSv1Modification" + effect = "Deny" + actions = [ + "ec2:ModifyInstanceMetadataOptions" + ] + resources = ["*"] + + condition { + test = "StringNotEquals" + variable = "ec2:MetadataHttpTokens" + values = ["required"] + } + } +} + +data "aws_iam_policy_document" "deny_root" { + statement { + sid = "DenyRootActions" + effect = "Deny" + not_actions = [ + # Allow essential root-only actions + "iam:CreateVirtualMFADevice", + "iam:EnableMFADevice", + "iam:GetAccountPasswordPolicy", + "iam:GetAccountSummary", + "iam:ListVirtualMFADevices", + "sts:GetSessionToken", + "support:*", + ] + resources = ["*"] + + condition { + test = "StringLike" + variable = "aws:PrincipalArn" + values = ["arn:aws:iam::*:root"] + } + } +} + +data "aws_iam_policy_document" "region_restriction" { + statement { + sid = "DenyNonAllowedRegions" + effect = "Deny" + not_actions = [ + # Global services - always allow + "iam:*", + "organizations:*", + "sts:*", + "support:*", + "budgets:*", + "cloudfront:*", + "route53:*", + "route53domains:*", + "waf:*", + "wafv2:*", + "waf-regional:*", + "health:*", + "trustedadvisor:*", + "globalaccelerator:*", + "shield:*", + "chime:*", + "aws-portal:*", + ] + resources = ["*"] + + condition { + test = "StringNotEquals" + variable = "aws:RequestedRegion" + values = var.allowed_regions + } + } +} + +data "aws_iam_policy_document" "protect_security" { + statement { + sid = "ProtectGuardDuty" + effect = "Deny" + actions = [ + "guardduty:DeleteDetector", + "guardduty:DeleteMembers", + "guardduty:DisassociateFromMasterAccount", + "guardduty:StopMonitoringMembers", + "guardduty:UpdateDetector", + ] + resources = ["*"] + } + + statement { + sid = "ProtectSecurityHub" + effect = "Deny" + actions = [ + "securityhub:DisableSecurityHub", + "securityhub:DeleteMembers", + "securityhub:DisassociateFromMasterAccount", + ] + resources = ["*"] + } + + statement { + sid = "ProtectConfig" + effect = "Deny" + actions = [ + "config:DeleteConfigRule", + "config:DeleteConfigurationRecorder", + "config:DeleteDeliveryChannel", + "config:StopConfigurationRecorder", + ] + resources = ["*"] + } + + statement { + sid = "ProtectAccessAnalyzer" + effect = "Deny" + actions = [ + "access-analyzer:DeleteAnalyzer", + ] + resources = ["*"] + } +} + +data "aws_iam_policy_document" "protect_cloudtrail" { + statement { + sid = "ProtectCloudTrail" + effect = "Deny" + actions = [ + "cloudtrail:DeleteTrail", + "cloudtrail:StopLogging", + "cloudtrail:UpdateTrail", + "cloudtrail:PutEventSelectors", + ] + resources = ["*"] + + # Allow org management account to manage org trail + condition { + test = "StringNotEquals" + variable = "aws:PrincipalOrgMasterAccountId" + values = ["${data.aws_caller_identity.current.account_id}"] + } + } +} + +data "aws_iam_policy_document" "require_s3_encryption" { + statement { + sid = "DenyUnencryptedS3PutObject" + effect = "Deny" + actions = [ + "s3:PutObject" + ] + resources = ["*"] + + condition { + test = "Null" + variable = "s3:x-amz-server-side-encryption" + values = ["true"] + } + } + + statement { + sid = "DenyWrongEncryptionType" + effect = "Deny" + actions = [ + "s3:PutObject" + ] + resources = ["*"] + + condition { + test = "StringNotEqualsIfExists" + variable = "s3:x-amz-server-side-encryption" + values = ["AES256", "aws:kms"] + } + } +} + +data "aws_iam_policy_document" "require_ebs_encryption" { + statement { + sid = "DenyUnencryptedVolume" + effect = "Deny" + actions = [ + "ec2:CreateVolume" + ] + resources = ["*"] + + condition { + test = "Bool" + variable = "ec2:Encrypted" + values = ["false"] + } + } + + statement { + sid = "DenyUnencryptedSnapshot" + effect = "Deny" + actions = [ + "ec2:RunInstances" + ] + resources = ["arn:aws:ec2:*::snapshot/*"] + + condition { + test = "Bool" + variable = "ec2:Encrypted" + values = ["false"] + } + } +} + +################################################################################ +# Data Sources +################################################################################ + +data "aws_caller_identity" "current" {} +data "aws_organizations_organization" "current" {} + +################################################################################ +# SCP Resources +################################################################################ + +resource "aws_organizations_policy" "this" { + for_each = local.scps + + name = each.value.name + description = each.value.description + type = "SERVICE_CONTROL_POLICY" + content = each.value.policy + + tags = merge(var.tags, { + Name = each.value.name + }) +} + +# Attach SCPs to specified OUs +resource "aws_organizations_policy_attachment" "ou" { + for_each = { + for pair in setproduct(keys(local.scps), var.target_ous) : "${pair[0]}-${pair[1]}" => { + policy_key = pair[0] + target_id = pair[1] + } + } + + policy_id = aws_organizations_policy.this[each.value.policy_key].id + target_id = each.value.target_id +} + +# Attach SCPs to specified accounts +resource "aws_organizations_policy_attachment" "account" { + for_each = { + for pair in setproduct(keys(local.scps), var.target_accounts) : "${pair[0]}-${pair[1]}" => { + policy_key = pair[0] + target_id = pair[1] + } + } + + policy_id = aws_organizations_policy.this[each.value.policy_key].id + target_id = each.value.target_id +} diff --git a/terraform/modules/scps/outputs.tf b/terraform/modules/scps/outputs.tf new file mode 100644 index 0000000..f93a633 --- /dev/null +++ b/terraform/modules/scps/outputs.tf @@ -0,0 +1,26 @@ +################################################################################ +# SCPs - Outputs +################################################################################ + +output "policy_ids" { + value = { for k, v in aws_organizations_policy.this : k => v.id } + description = "Map of SCP names to policy IDs" +} + +output "policy_arns" { + value = { for k, v in aws_organizations_policy.this : k => v.arn } + description = "Map of SCP names to policy ARNs" +} + +output "enabled_policies" { + value = keys(local.scps) + description = "List of enabled SCP policy names" +} + +output "attachment_count" { + value = { + ous = length(var.target_ous) + accounts = length(var.target_accounts) + } + description = "Count of SCP attachments" +} diff --git a/terraform/modules/scps/variables.tf b/terraform/modules/scps/variables.tf new file mode 100644 index 0000000..b60c1bb --- /dev/null +++ b/terraform/modules/scps/variables.tf @@ -0,0 +1,75 @@ +################################################################################ +# SCPs - Input Variables +################################################################################ + +variable "name_prefix" { + type = string + default = "scp" + description = "Prefix for SCP names" +} + +variable "enable_deny_leave_org" { + type = bool + default = true + description = "Prevent accounts from leaving organization" +} + +variable "enable_require_imdsv2" { + type = bool + default = true + description = "Require IMDSv2 for EC2 instances" +} + +variable "enable_deny_root_actions" { + type = bool + default = true + description = "Deny most actions by root user" +} + +variable "allowed_regions" { + type = list(string) + default = [] + description = "Allowed regions (empty = all regions allowed)" +} + +variable "protect_security_services" { + type = bool + default = true + description = "Prevent disabling GuardDuty, Security Hub, Config, Access Analyzer" +} + +variable "protect_cloudtrail" { + type = bool + default = true + description = "Prevent CloudTrail modification" +} + +variable "require_s3_encryption" { + type = bool + default = true + description = "Require S3 bucket encryption" +} + +variable "require_ebs_encryption" { + type = bool + default = true + description = "Require EBS volume encryption" +} + +variable "target_ous" { + type = list(string) + default = [] + description = "OU IDs to attach SCPs to" +} + +variable "target_accounts" { + type = list(string) + default = [] + description = "Account IDs to attach SCPs to" +} + +variable "tags" { + type = map(string) + default = {} + description = "Tags to apply to SCP resources" +} diff --git a/terraform/modules/security-baseline/README.md b/terraform/modules/security-baseline/README.md new file mode 100644 index 0000000..fceba4b --- /dev/null +++ b/terraform/modules/security-baseline/README.md @@ -0,0 +1,54 @@ +# security-baseline + +Security Baseline Module + +## Usage + +```hcl +module "security_baseline" { + source = "../modules/security-baseline" + + # Required variables + name = "" + config_bucket_name = "" + + # Optional: see variables.tf for all options +} +``` + +## Requirements + +| Name | Version | +|------|---------| +| terraform | >= 1.5.0 | +| aws | >= 5.0 | + +## Inputs + +| Name | Description | Type | Required | +|------|-------------|------|----------| +| name | Name prefix for resources | `string` | yes | +| enable_guardduty | | `bool` | no | +| enable_securityhub | | `bool` | no | +| enable_config | | `bool` | no | +| enable_access_analyzer | | `bool` | no | +| enable_macie | Macie for S3 data classification (additional cost) | `bool` | no | +| config_bucket_name | S3 bucket for AWS Config recordings | `string` | yes | +| guardduty_finding_publishing_frequency | | `string` | no | +| securityhub_standards | Security Hub standards to enable | `list(string)` | no | +| config_rules | Additional AWS Config managed rule identifiers to enable | `list(string)` | no | +| tags | | `map(string)` | no | + +## Outputs + +| Name | Description | +|------|-------------| +| guardduty_detector_id | | +| securityhub_account_id | | +| config_recorder_id | | +| access_analyzer_arn | | +| enabled_services | | + +## License + +Apache 2.0 - See LICENSE for details. diff --git a/terraform/modules/security-baseline/main.tf b/terraform/modules/security-baseline/main.tf new file mode 100644 index 0000000..e535788 --- /dev/null +++ b/terraform/modules/security-baseline/main.tf @@ -0,0 +1,334 @@ +################################################################################ +# Security Baseline Module +# +# Enables core AWS security services: +# - GuardDuty (threat detection) +# - Security Hub (security posture) +# - AWS Config (configuration compliance) +# - IAM Access Analyzer +# +# For multi-account: Deploy in management account, then enable delegated admin +################################################################################ + +terraform { + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 5.0" + } + } +} + +variable "name" { + type = string + description = "Name prefix for resources" +} + +variable "enable_guardduty" { + type = bool + default = true +} + +variable "enable_securityhub" { + type = bool + default = true +} + +variable "enable_config" { + type = bool + default = true +} + +variable "enable_access_analyzer" { + type = bool + default = true +} + +variable "enable_macie" { + type = bool + default = false + description = "Macie for S3 data classification (additional cost)" +} + +variable "config_bucket_name" { + type = string + description = "S3 bucket for AWS Config recordings" +} + +variable "guardduty_finding_publishing_frequency" { + type = string + default = "FIFTEEN_MINUTES" + validation { + condition = contains(["FIFTEEN_MINUTES", "ONE_HOUR", "SIX_HOURS"], var.guardduty_finding_publishing_frequency) + error_message = "Must be FIFTEEN_MINUTES, ONE_HOUR, or SIX_HOURS" + } +} + +variable "securityhub_standards" { + type = list(string) + default = [ + "aws-foundational-security-best-practices/v/1.0.0", + "cis-aws-foundations-benchmark/v/1.4.0", + ] + description = "Security Hub standards to enable" +} + +variable "config_rules" { + type = list(string) + default = [] + description = "Additional AWS Config managed rule identifiers to enable" +} + +variable "tags" { + type = map(string) + default = {} +} + +data "aws_caller_identity" "current" {} +data "aws_region" "current" {} + +################################################################################ +# GuardDuty +################################################################################ + +resource "aws_guardduty_detector" "main" { + count = var.enable_guardduty ? 1 : 0 + + enable = true + finding_publishing_frequency = var.guardduty_finding_publishing_frequency + + datasources { + s3_logs { + enable = true + } + kubernetes { + audit_logs { + enable = true + } + } + malware_protection { + scan_ec2_instance_with_findings { + ebs_volumes { + enable = true + } + } + } + } + + tags = merge(var.tags, { Name = "${var.name}-guardduty" }) +} + +################################################################################ +# Security Hub +################################################################################ + +resource "aws_securityhub_account" "main" { + count = var.enable_securityhub ? 1 : 0 + + enable_default_standards = false + auto_enable_controls = true + + depends_on = [aws_guardduty_detector.main] +} + +resource "aws_securityhub_standards_subscription" "standards" { + for_each = var.enable_securityhub ? toset(var.securityhub_standards) : [] + + standards_arn = "arn:aws:securityhub:${data.aws_region.current.name}::standards/${each.value}" + + depends_on = [aws_securityhub_account.main] +} + +################################################################################ +# AWS Config +################################################################################ + +resource "aws_config_configuration_recorder" "main" { + count = var.enable_config ? 1 : 0 + + name = var.name + role_arn = aws_iam_role.config[0].arn + + recording_group { + all_supported = true + include_global_resource_types = true + } + + recording_mode { + recording_frequency = "CONTINUOUS" + } +} + +resource "aws_config_delivery_channel" "main" { + count = var.enable_config ? 1 : 0 + + name = var.name + s3_bucket_name = var.config_bucket_name + s3_key_prefix = "config" + + snapshot_delivery_properties { + delivery_frequency = "TwentyFour_Hours" + } + + depends_on = [aws_config_configuration_recorder.main] +} + +resource "aws_config_configuration_recorder_status" "main" { + count = var.enable_config ? 1 : 0 + + name = aws_config_configuration_recorder.main[0].name + is_enabled = true + + depends_on = [aws_config_delivery_channel.main] +} + +# IAM Role for Config +resource "aws_iam_role" "config" { + count = var.enable_config ? 1 : 0 + name = "${var.name}-config" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Effect = "Allow" + Action = "sts:AssumeRole" + Principal = { Service = "config.amazonaws.com" } + }] + }) + + tags = merge(var.tags, { Name = "${var.name}-config" }) +} + +resource "aws_iam_role_policy_attachment" "config" { + count = var.enable_config ? 1 : 0 + role = aws_iam_role.config[0].name + policy_arn = "arn:aws:iam::aws:policy/service-role/AWS_ConfigRole" +} + +resource "aws_iam_role_policy" "config_s3" { + count = var.enable_config ? 1 : 0 + name = "s3-delivery" + role = aws_iam_role.config[0].id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Effect = "Allow" + Action = ["s3:PutObject", "s3:PutObjectAcl"] + Resource = "arn:aws:s3:::${var.config_bucket_name}/config/*" + Condition = { + StringEquals = { + "s3:x-amz-acl" = "bucket-owner-full-control" + } + } + }, { + Effect = "Allow" + Action = ["s3:GetBucketAcl"] + Resource = "arn:aws:s3:::${var.config_bucket_name}" + }] + }) +} + +################################################################################ +# AWS Config Rules - Security Best Practices +################################################################################ + +locals { + default_config_rules = [ + "ENCRYPTED_VOLUMES", + "RDS_STORAGE_ENCRYPTED", + "S3_BUCKET_SERVER_SIDE_ENCRYPTION_ENABLED", + "S3_BUCKET_SSL_REQUESTS_ONLY", + "S3_BUCKET_PUBLIC_READ_PROHIBITED", + "S3_BUCKET_PUBLIC_WRITE_PROHIBITED", + "RESTRICTED_SSH", + "VPC_DEFAULT_SECURITY_GROUP_CLOSED", + "VPC_FLOW_LOGS_ENABLED", + "CLOUD_TRAIL_ENABLED", + "CLOUD_TRAIL_ENCRYPTION_ENABLED", + "CLOUD_TRAIL_LOG_FILE_VALIDATION_ENABLED", + "IAM_ROOT_ACCESS_KEY_CHECK", + "IAM_USER_MFA_ENABLED", + "MFA_ENABLED_FOR_IAM_CONSOLE_ACCESS", + "ROOT_ACCOUNT_MFA_ENABLED", + "RDS_INSTANCE_PUBLIC_ACCESS_CHECK", + "GUARDDUTY_ENABLED_CENTRALIZED", + "SECURITYHUB_ENABLED", + "EBS_OPTIMIZED_INSTANCE", + "EC2_IMDSV2_CHECK", + "EKS_SECRETS_ENCRYPTED", + "LAMBDA_FUNCTION_PUBLIC_ACCESS_PROHIBITED", + "LAMBDA_INSIDE_VPC", + ] + + all_config_rules = distinct(concat(local.default_config_rules, var.config_rules)) +} + +resource "aws_config_config_rule" "rules" { + for_each = var.enable_config ? toset(local.all_config_rules) : [] + + name = lower(replace(each.value, "_", "-")) + + source { + owner = "AWS" + source_identifier = each.value + } + + depends_on = [aws_config_configuration_recorder_status.main] + + tags = merge(var.tags, { Name = lower(replace(each.value, "_", "-")) }) +} + +################################################################################ +# IAM Access Analyzer +################################################################################ + +resource "aws_accessanalyzer_analyzer" "main" { + count = var.enable_access_analyzer ? 1 : 0 + + analyzer_name = var.name + type = "ACCOUNT" + + tags = merge(var.tags, { Name = "${var.name}-access-analyzer" }) +} + +################################################################################ +# Macie (Optional) +################################################################################ + +resource "aws_macie2_account" "main" { + count = var.enable_macie ? 1 : 0 + + finding_publishing_frequency = "FIFTEEN_MINUTES" + status = "ENABLED" +} + +################################################################################ +# Outputs +################################################################################ + +output "guardduty_detector_id" { + value = var.enable_guardduty ? aws_guardduty_detector.main[0].id : null +} + +output "securityhub_account_id" { + value = var.enable_securityhub ? aws_securityhub_account.main[0].id : null +} + +output "config_recorder_id" { + value = var.enable_config ? aws_config_configuration_recorder.main[0].id : null +} + +output "access_analyzer_arn" { + value = var.enable_access_analyzer ? aws_accessanalyzer_analyzer.main[0].arn : null +} + +output "enabled_services" { + value = { + guardduty = var.enable_guardduty + securityhub = var.enable_securityhub + config = var.enable_config + access_analyzer = var.enable_access_analyzer + macie = var.enable_macie + } +} diff --git a/terraform/modules/security-groups/README.md b/terraform/modules/security-groups/README.md new file mode 100644 index 0000000..b321494 --- /dev/null +++ b/terraform/modules/security-groups/README.md @@ -0,0 +1,34 @@ +# security-groups + +Terraform module for AWS landing zone pattern. + +Create common security group patterns for multi-tier architectures. + +## Planned Features + +- [ ] Web tier (HTTP/HTTPS from ALB) +- [ ] App tier (from web tier only) +- [ ] Database tier (from app tier only) +- [ ] Bastion host (SSH from allowed CIDRs) +- [ ] VPC endpoints (HTTPS from VPC) +- [ ] EKS patterns (cluster, nodes, pods) + +## Planned Usage + +```hcl +module "security_groups" { + source = "../modules/security-groups" + + vpc_id = module.vpc.vpc_id + name_prefix = "myapp" + + create_web_tier = true + create_app_tier = true + create_db_tier = true + create_bastion = true + + allowed_ssh_cidrs = ["10.0.0.0/8"] + + tags = local.tags +} +``` diff --git a/terraform/modules/security-groups/main.tf b/terraform/modules/security-groups/main.tf new file mode 100644 index 0000000..3285dc2 --- /dev/null +++ b/terraform/modules/security-groups/main.tf @@ -0,0 +1,395 @@ +################################################################################ +# Security Groups Module +# +# Creates common security group patterns for multi-tier architectures: +# - Web tier (HTTP/HTTPS from ALB or internet) +# - App tier (from web tier only) +# - Database tier (from app tier only) +# - Bastion host (SSH from allowed CIDRs) +# - VPC endpoints (HTTPS from VPC) +# - EKS patterns (cluster, nodes) +################################################################################ + +terraform { + required_version = ">= 1.5.0" + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 5.0" + } + } +} + +data "aws_vpc" "selected" { + id = var.vpc_id +} + +locals { + vpc_cidr = data.aws_vpc.selected.cidr_block +} + +################################################################################ +# Web Tier Security Group +################################################################################ + +resource "aws_security_group" "web" { + count = var.create_web_tier ? 1 : 0 + + name_prefix = "${var.name_prefix}-web-" + description = "Web tier - HTTP/HTTPS access" + vpc_id = var.vpc_id + + tags = merge(var.tags, { + Name = "${var.name_prefix}-web" + Tier = "web" + }) + + lifecycle { + create_before_destroy = true + } +} + +resource "aws_vpc_security_group_ingress_rule" "web_http" { + count = var.create_web_tier ? 1 : 0 + + security_group_id = aws_security_group.web[0].id + description = "HTTP from allowed sources" + from_port = 80 + to_port = 80 + ip_protocol = "tcp" + cidr_ipv4 = var.web_ingress_cidr + + tags = { Name = "http-ingress" } +} + +resource "aws_vpc_security_group_ingress_rule" "web_https" { + count = var.create_web_tier ? 1 : 0 + + security_group_id = aws_security_group.web[0].id + description = "HTTPS from allowed sources" + from_port = 443 + to_port = 443 + ip_protocol = "tcp" + cidr_ipv4 = var.web_ingress_cidr + + tags = { Name = "https-ingress" } +} + +resource "aws_vpc_security_group_egress_rule" "web_all" { + count = var.create_web_tier ? 1 : 0 + + security_group_id = aws_security_group.web[0].id + description = "Allow all outbound" + ip_protocol = "-1" + cidr_ipv4 = "0.0.0.0/0" + + tags = { Name = "all-egress" } +} + +################################################################################ +# App Tier Security Group +################################################################################ + +resource "aws_security_group" "app" { + count = var.create_app_tier ? 1 : 0 + + name_prefix = "${var.name_prefix}-app-" + description = "App tier - access from web tier" + vpc_id = var.vpc_id + + tags = merge(var.tags, { + Name = "${var.name_prefix}-app" + Tier = "app" + }) + + lifecycle { + create_before_destroy = true + } +} + +resource "aws_vpc_security_group_ingress_rule" "app_from_web" { + count = var.create_app_tier && var.create_web_tier ? 1 : 0 + + security_group_id = aws_security_group.app[0].id + description = "App port from web tier" + from_port = var.app_port + to_port = var.app_port + ip_protocol = "tcp" + referenced_security_group_id = aws_security_group.web[0].id + + tags = { Name = "from-web-tier" } +} + +resource "aws_vpc_security_group_ingress_rule" "app_from_cidr" { + count = var.create_app_tier && !var.create_web_tier ? 1 : 0 + + security_group_id = aws_security_group.app[0].id + description = "App port from VPC" + from_port = var.app_port + to_port = var.app_port + ip_protocol = "tcp" + cidr_ipv4 = local.vpc_cidr + + tags = { Name = "from-vpc" } +} + +resource "aws_vpc_security_group_egress_rule" "app_all" { + count = var.create_app_tier ? 1 : 0 + + security_group_id = aws_security_group.app[0].id + description = "Allow all outbound" + ip_protocol = "-1" + cidr_ipv4 = "0.0.0.0/0" + + tags = { Name = "all-egress" } +} + +################################################################################ +# Database Tier Security Group +################################################################################ + +resource "aws_security_group" "db" { + count = var.create_db_tier ? 1 : 0 + + name_prefix = "${var.name_prefix}-db-" + description = "Database tier - access from app tier" + vpc_id = var.vpc_id + + tags = merge(var.tags, { + Name = "${var.name_prefix}-db" + Tier = "database" + }) + + lifecycle { + create_before_destroy = true + } +} + +resource "aws_vpc_security_group_ingress_rule" "db_from_app" { + count = var.create_db_tier && var.create_app_tier ? 1 : 0 + + security_group_id = aws_security_group.db[0].id + description = "Database port from app tier" + from_port = var.db_port + to_port = var.db_port + ip_protocol = "tcp" + referenced_security_group_id = aws_security_group.app[0].id + + tags = { Name = "from-app-tier" } +} + +resource "aws_vpc_security_group_ingress_rule" "db_from_cidr" { + count = var.create_db_tier && !var.create_app_tier ? 1 : 0 + + security_group_id = aws_security_group.db[0].id + description = "Database port from VPC" + from_port = var.db_port + to_port = var.db_port + ip_protocol = "tcp" + cidr_ipv4 = local.vpc_cidr + + tags = { Name = "from-vpc" } +} + +resource "aws_vpc_security_group_egress_rule" "db_all" { + count = var.create_db_tier ? 1 : 0 + + security_group_id = aws_security_group.db[0].id + description = "Allow all outbound" + ip_protocol = "-1" + cidr_ipv4 = "0.0.0.0/0" + + tags = { Name = "all-egress" } +} + +################################################################################ +# Bastion Security Group +################################################################################ + +resource "aws_security_group" "bastion" { + count = var.create_bastion ? 1 : 0 + + name_prefix = "${var.name_prefix}-bastion-" + description = "Bastion host - SSH from allowed CIDRs" + vpc_id = var.vpc_id + + tags = merge(var.tags, { + Name = "${var.name_prefix}-bastion" + Tier = "bastion" + }) + + lifecycle { + create_before_destroy = true + } +} + +resource "aws_vpc_security_group_ingress_rule" "bastion_ssh" { + for_each = var.create_bastion ? toset(var.allowed_ssh_cidrs) : [] + + security_group_id = aws_security_group.bastion[0].id + description = "SSH from ${each.value}" + from_port = 22 + to_port = 22 + ip_protocol = "tcp" + cidr_ipv4 = each.value + + tags = { Name = "ssh-from-${replace(each.value, "/", "-")}" } +} + +resource "aws_vpc_security_group_egress_rule" "bastion_all" { + count = var.create_bastion ? 1 : 0 + + security_group_id = aws_security_group.bastion[0].id + description = "Allow all outbound" + ip_protocol = "-1" + cidr_ipv4 = "0.0.0.0/0" + + tags = { Name = "all-egress" } +} + +################################################################################ +# VPC Endpoints Security Group +################################################################################ + +resource "aws_security_group" "endpoints" { + count = var.create_endpoints ? 1 : 0 + + name_prefix = "${var.name_prefix}-endpoints-" + description = "VPC Endpoints - HTTPS from VPC" + vpc_id = var.vpc_id + + tags = merge(var.tags, { + Name = "${var.name_prefix}-endpoints" + Tier = "endpoints" + }) + + lifecycle { + create_before_destroy = true + } +} + +resource "aws_vpc_security_group_ingress_rule" "endpoints_https" { + count = var.create_endpoints ? 1 : 0 + + security_group_id = aws_security_group.endpoints[0].id + description = "HTTPS from VPC" + from_port = 443 + to_port = 443 + ip_protocol = "tcp" + cidr_ipv4 = local.vpc_cidr + + tags = { Name = "https-from-vpc" } +} + +resource "aws_vpc_security_group_egress_rule" "endpoints_all" { + count = var.create_endpoints ? 1 : 0 + + security_group_id = aws_security_group.endpoints[0].id + description = "Allow all outbound" + ip_protocol = "-1" + cidr_ipv4 = "0.0.0.0/0" + + tags = { Name = "all-egress" } +} + +################################################################################ +# EKS Cluster Security Group +################################################################################ + +resource "aws_security_group" "eks_cluster" { + count = var.create_eks ? 1 : 0 + + name_prefix = "${var.name_prefix}-eks-cluster-" + description = "EKS cluster control plane" + vpc_id = var.vpc_id + + tags = merge(var.tags, { + Name = "${var.name_prefix}-eks-cluster" + Tier = "eks" + }) + + lifecycle { + create_before_destroy = true + } +} + +resource "aws_vpc_security_group_ingress_rule" "eks_cluster_https" { + count = var.create_eks ? 1 : 0 + + security_group_id = aws_security_group.eks_cluster[0].id + description = "HTTPS from VPC (kubectl)" + from_port = 443 + to_port = 443 + ip_protocol = "tcp" + cidr_ipv4 = local.vpc_cidr + + tags = { Name = "https-from-vpc" } +} + +resource "aws_vpc_security_group_egress_rule" "eks_cluster_all" { + count = var.create_eks ? 1 : 0 + + security_group_id = aws_security_group.eks_cluster[0].id + description = "Allow all outbound" + ip_protocol = "-1" + cidr_ipv4 = "0.0.0.0/0" + + tags = { Name = "all-egress" } +} + +################################################################################ +# EKS Nodes Security Group +################################################################################ + +resource "aws_security_group" "eks_nodes" { + count = var.create_eks ? 1 : 0 + + name_prefix = "${var.name_prefix}-eks-nodes-" + description = "EKS worker nodes" + vpc_id = var.vpc_id + + tags = merge(var.tags, { + Name = "${var.name_prefix}-eks-nodes" + Tier = "eks" + "kubernetes.io/cluster/${var.name_prefix}" = "owned" + }) + + lifecycle { + create_before_destroy = true + } +} + +resource "aws_vpc_security_group_ingress_rule" "eks_nodes_self" { + count = var.create_eks ? 1 : 0 + + security_group_id = aws_security_group.eks_nodes[0].id + description = "Node to node communication" + ip_protocol = "-1" + referenced_security_group_id = aws_security_group.eks_nodes[0].id + + tags = { Name = "node-to-node" } +} + +resource "aws_vpc_security_group_ingress_rule" "eks_nodes_cluster" { + count = var.create_eks ? 1 : 0 + + security_group_id = aws_security_group.eks_nodes[0].id + description = "From cluster control plane" + from_port = 1025 + to_port = 65535 + ip_protocol = "tcp" + referenced_security_group_id = aws_security_group.eks_cluster[0].id + + tags = { Name = "from-cluster" } +} + +resource "aws_vpc_security_group_egress_rule" "eks_nodes_all" { + count = var.create_eks ? 1 : 0 + + security_group_id = aws_security_group.eks_nodes[0].id + description = "Allow all outbound" + ip_protocol = "-1" + cidr_ipv4 = "0.0.0.0/0" + + tags = { Name = "all-egress" } +} diff --git a/terraform/modules/security-groups/outputs.tf b/terraform/modules/security-groups/outputs.tf new file mode 100644 index 0000000..ee52651 --- /dev/null +++ b/terraform/modules/security-groups/outputs.tf @@ -0,0 +1,51 @@ +################################################################################ +# Security Groups - Outputs +################################################################################ + +output "web_tier_sg_id" { + value = try(aws_security_group.web[0].id, null) + description = "Web tier security group ID" +} + +output "app_tier_sg_id" { + value = try(aws_security_group.app[0].id, null) + description = "App tier security group ID" +} + +output "db_tier_sg_id" { + value = try(aws_security_group.db[0].id, null) + description = "Database tier security group ID" +} + +output "bastion_sg_id" { + value = try(aws_security_group.bastion[0].id, null) + description = "Bastion security group ID" +} + +output "endpoints_sg_id" { + value = try(aws_security_group.endpoints[0].id, null) + description = "VPC endpoints security group ID" +} + +output "eks_cluster_sg_id" { + value = try(aws_security_group.eks_cluster[0].id, null) + description = "EKS cluster security group ID" +} + +output "eks_nodes_sg_id" { + value = try(aws_security_group.eks_nodes[0].id, null) + description = "EKS nodes security group ID" +} + +output "all_sg_ids" { + value = { + web = try(aws_security_group.web[0].id, null) + app = try(aws_security_group.app[0].id, null) + db = try(aws_security_group.db[0].id, null) + bastion = try(aws_security_group.bastion[0].id, null) + endpoints = try(aws_security_group.endpoints[0].id, null) + eks_cluster = try(aws_security_group.eks_cluster[0].id, null) + eks_nodes = try(aws_security_group.eks_nodes[0].id, null) + } + description = "Map of all security group IDs" +} diff --git a/terraform/modules/security-groups/variables.tf b/terraform/modules/security-groups/variables.tf new file mode 100644 index 0000000..ee0a4f6 --- /dev/null +++ b/terraform/modules/security-groups/variables.tf @@ -0,0 +1,79 @@ +################################################################################ +# Security Groups - Input Variables +################################################################################ + +variable "vpc_id" { + type = string + description = "VPC ID to create security groups in" +} + +variable "name_prefix" { + type = string + description = "Prefix for security group names" +} + +variable "create_web_tier" { + type = bool + default = false + description = "Create web tier security group" +} + +variable "create_app_tier" { + type = bool + default = false + description = "Create application tier security group" +} + +variable "create_db_tier" { + type = bool + default = false + description = "Create database tier security group" +} + +variable "create_bastion" { + type = bool + default = false + description = "Create bastion host security group" +} + +variable "create_endpoints" { + type = bool + default = false + description = "Create VPC endpoints security group" +} + +variable "create_eks" { + type = bool + default = false + description = "Create EKS cluster and node security groups" +} + +variable "web_ingress_cidr" { + type = string + default = "0.0.0.0/0" + description = "CIDR for web tier ingress (use ALB SG for production)" +} + +variable "app_port" { + type = number + default = 8080 + description = "Application port for app tier" +} + +variable "db_port" { + type = number + default = 5432 + description = "Database port (5432=PostgreSQL, 3306=MySQL)" +} + +variable "allowed_ssh_cidrs" { + type = list(string) + default = [] + description = "CIDRs allowed SSH access to bastion" +} + +variable "tags" { + type = map(string) + default = {} + description = "Tags to apply to security groups" +} diff --git a/terraform/modules/shared-vpc/README.md b/terraform/modules/shared-vpc/README.md new file mode 100644 index 0000000..8b73624 --- /dev/null +++ b/terraform/modules/shared-vpc/README.md @@ -0,0 +1,51 @@ +# shared-vpc + +Shared VPC Module Single VPC shared across all tenants via AWS RAM Isolation via: Security Groups, ABAC (tags), optional subnet segmentation + +## Usage + +```hcl +module "shared_vpc" { + source = "../modules/shared-vpc" + + # Required variables + workloads_ou_arn = "" + + # Optional: see variables.tf for all options +} +``` + +## Requirements + +| Name | Version | +|------|---------| +| terraform | >= 1.5.0 | +| aws | >= 5.0 | + +## Inputs + +| Name | Description | Type | Required | +|------|-------------|------|----------| +| vpc_cidr | CIDR block for the shared VPC | `string` | no | +| tenant_subnet_cidr | CIDR block for tenant-specific subnets (if enabled) | `string` | no | +| availability_zones | List of availability zones | `list(string)` | no | +| enable_nat_gateway | Enable NAT Gateway for private subnet internet access | `bool` | no | +| tenants | List of tenant names (for per-tenant subnets) | `list(string)` | no | +| create_tenant_subnets | Create separate subnets per tenant (stricter isolation) | `bool` | no | +| workloads_ou_arn | ARN of the Workloads OU to share subnets with | `string` | yes | + +## Outputs + +| Name | Description | +|------|-------------| +| vpc_id | | +| vpc_cidr | | +| public_subnet_ids | | +| private_shared_subnet_ids | | +| private_tenant_subnet_ids | | +| nat_gateway_ip | | +| ram_share_arn | | + +## License + +Apache 2.0 - See LICENSE for details. diff --git a/terraform/modules/shared-vpc/main.tf b/terraform/modules/shared-vpc/main.tf new file mode 100644 index 0000000..00dceb0 --- /dev/null +++ b/terraform/modules/shared-vpc/main.tf @@ -0,0 +1,267 @@ +################################################################################ +# Shared VPC Module +# Single VPC shared across all tenants via AWS RAM +# Isolation via: Security Groups, ABAC (tags), optional subnet segmentation +################################################################################ + +terraform { + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 5.0" + } + } +} + +################################################################################ +# VPC +################################################################################ + +resource "aws_vpc" "shared" { + cidr_block = var.vpc_cidr + enable_dns_hostnames = true + enable_dns_support = true + + tags = { + Name = "shared-vpc" + Environment = "shared" + ManagedBy = "terraform" + } +} + +################################################################################ +# Internet Gateway +################################################################################ + +resource "aws_internet_gateway" "shared" { + vpc_id = aws_vpc.shared.id + + tags = { + Name = "shared-igw" + } +} + +################################################################################ +# NAT Gateway (Single for cost savings) +################################################################################ + +resource "aws_eip" "nat" { + count = var.enable_nat_gateway ? 1 : 0 + domain = "vpc" + + tags = { + Name = "shared-nat-eip" + } +} + +resource "aws_nat_gateway" "shared" { + count = var.enable_nat_gateway ? 1 : 0 + + allocation_id = aws_eip.nat[0].id + subnet_id = aws_subnet.public[0].id + + tags = { + Name = "shared-nat" + } + + depends_on = [aws_internet_gateway.shared] +} + +################################################################################ +# Subnets - Public (shared) +################################################################################ + +resource "aws_subnet" "public" { + count = length(var.availability_zones) + + vpc_id = aws_vpc.shared.id + cidr_block = cidrsubnet(var.vpc_cidr, 4, count.index) + availability_zone = var.availability_zones[count.index] + map_public_ip_on_launch = true + + tags = { + Name = "shared-public-${var.availability_zones[count.index]}" + Type = "public" + Environment = "shared" + } +} + +################################################################################ +# Subnets - Private (shared across tenants) +################################################################################ + +resource "aws_subnet" "private_shared" { + count = length(var.availability_zones) + + vpc_id = aws_vpc.shared.id + cidr_block = cidrsubnet(var.vpc_cidr, 4, count.index + length(var.availability_zones)) + availability_zone = var.availability_zones[count.index] + + tags = { + Name = "shared-private-${var.availability_zones[count.index]}" + Type = "private" + Environment = "shared" + } +} + +################################################################################ +# Subnets - Per-Tenant Private (optional, for stricter isolation) +################################################################################ + +resource "aws_subnet" "private_tenant" { + for_each = var.create_tenant_subnets ? { + for combo in setproduct(var.tenants, range(length(var.availability_zones))) : + "${combo[0]}-${combo[1]}" => { + tenant = combo[0] + az_idx = combo[1] + } + } : {} + + vpc_id = aws_vpc.shared.id + cidr_block = cidrsubnet(var.tenant_subnet_cidr, 4, index(var.tenants, each.value.tenant) * length(var.availability_zones) + each.value.az_idx) + availability_zone = var.availability_zones[each.value.az_idx] + + tags = { + Name = "tenant-${each.value.tenant}-private-${var.availability_zones[each.value.az_idx]}" + Type = "private" + Tenant = each.value.tenant + Environment = "shared" + } +} + +################################################################################ +# Route Tables +################################################################################ + +resource "aws_route_table" "public" { + vpc_id = aws_vpc.shared.id + + route { + cidr_block = "0.0.0.0/0" + gateway_id = aws_internet_gateway.shared.id + } + + tags = { + Name = "shared-public-rt" + } +} + +resource "aws_route_table" "private" { + vpc_id = aws_vpc.shared.id + + dynamic "route" { + for_each = var.enable_nat_gateway ? [1] : [] + content { + cidr_block = "0.0.0.0/0" + nat_gateway_id = aws_nat_gateway.shared[0].id + } + } + + tags = { + Name = "shared-private-rt" + } +} + +resource "aws_route_table_association" "public" { + count = length(var.availability_zones) + + subnet_id = aws_subnet.public[count.index].id + route_table_id = aws_route_table.public.id +} + +resource "aws_route_table_association" "private_shared" { + count = length(var.availability_zones) + + subnet_id = aws_subnet.private_shared[count.index].id + route_table_id = aws_route_table.private.id +} + +resource "aws_route_table_association" "private_tenant" { + for_each = aws_subnet.private_tenant + + subnet_id = each.value.id + route_table_id = aws_route_table.private.id +} + +################################################################################ +# AWS RAM - Share VPC Subnets with Organization +################################################################################ + +resource "aws_ram_resource_share" "vpc_subnets" { + name = "shared-vpc-subnets" + allow_external_principals = false + + tags = { + Name = "Shared VPC Subnets" + } +} + +# Share private subnets with the organization +resource "aws_ram_resource_association" "private_shared" { + count = length(var.availability_zones) + + resource_arn = aws_subnet.private_shared[count.index].arn + resource_share_arn = aws_ram_resource_share.vpc_subnets.arn +} + +# Share tenant-specific subnets (if created) +resource "aws_ram_resource_association" "private_tenant" { + for_each = aws_subnet.private_tenant + + resource_arn = each.value.arn + resource_share_arn = aws_ram_resource_share.vpc_subnets.arn +} + +# Share with specific OUs or entire org +resource "aws_ram_principal_association" "workloads_ou" { + principal = var.workloads_ou_arn + resource_share_arn = aws_ram_resource_share.vpc_subnets.arn +} + +################################################################################ +# Default Security Group - Deny All (force explicit SGs) +################################################################################ + +resource "aws_default_security_group" "default" { + vpc_id = aws_vpc.shared.id + + # No ingress or egress rules = deny all + tags = { + Name = "default-deny-all" + Description = "Default SG - no access, use tenant-specific SGs" + } +} + +################################################################################ +# Outputs +################################################################################ + +output "vpc_id" { + value = aws_vpc.shared.id +} + +output "vpc_cidr" { + value = aws_vpc.shared.cidr_block +} + +output "public_subnet_ids" { + value = aws_subnet.public[*].id +} + +output "private_shared_subnet_ids" { + value = aws_subnet.private_shared[*].id +} + +output "private_tenant_subnet_ids" { + value = { + for k, v in aws_subnet.private_tenant : k => v.id + } +} + +output "nat_gateway_ip" { + value = var.enable_nat_gateway ? aws_eip.nat[0].public_ip : null +} + +output "ram_share_arn" { + value = aws_ram_resource_share.vpc_subnets.arn +} diff --git a/terraform/modules/shared-vpc/variables.tf b/terraform/modules/shared-vpc/variables.tf new file mode 100644 index 0000000..40e7531 --- /dev/null +++ b/terraform/modules/shared-vpc/variables.tf @@ -0,0 +1,40 @@ +variable "vpc_cidr" { + description = "CIDR block for the shared VPC" + type = string + default = "10.0.0.0/16" +} + +variable "tenant_subnet_cidr" { + description = "CIDR block for tenant-specific subnets (if enabled)" + type = string + default = "10.1.0.0/16" +} + +variable "availability_zones" { + description = "List of availability zones" + type = list(string) + default = ["us-east-1a", "us-east-1b", "us-east-1c"] +} + +variable "enable_nat_gateway" { + description = "Enable NAT Gateway for private subnet internet access" + type = bool + default = true +} + +variable "tenants" { + description = "List of tenant names (for per-tenant subnets)" + type = list(string) + default = [] +} + +variable "create_tenant_subnets" { + description = "Create separate subnets per tenant (stricter isolation)" + type = bool + default = false +} + +variable "workloads_ou_arn" { + description = "ARN of the Workloads OU to share subnets with" + type = string +} diff --git a/terraform/modules/tenant-baseline/README.md b/terraform/modules/tenant-baseline/README.md new file mode 100644 index 0000000..e3d3b37 --- /dev/null +++ b/terraform/modules/tenant-baseline/README.md @@ -0,0 +1,38 @@ +# tenant-baseline + +Terraform module for AWS landing zone pattern. + +Apply tenant-specific baseline for multi-tenant architectures. + +## Planned Features + +- [ ] Tenant-specific IAM roles with boundaries +- [ ] Tenant budget alerts +- [ ] Tenant tagging enforcement +- [ ] Dedicated or shared VPC networking +- [ ] Cost allocation tag setup + +## Planned Usage + +```hcl +module "tenant" { + source = "../modules/tenant-baseline" + + tenant_name = "acme-corp" + tenant_id = "acme" + environment = "prod" + cost_center = "CC-12345" + owner_email = "admin@acme.com" + budget_limit = 500 + + # Dedicated VPC (optional) + vpc_config = { + cidr = "10.100.0.0/16" + azs = ["us-east-1a", "us-east-1b"] + private_subnets = ["10.100.1.0/24", "10.100.2.0/24"] + public_subnets = ["10.100.101.0/24", "10.100.102.0/24"] + } + + tags = local.tags +} +``` diff --git a/terraform/modules/tenant-baseline/main.tf b/terraform/modules/tenant-baseline/main.tf new file mode 100644 index 0000000..1a91691 --- /dev/null +++ b/terraform/modules/tenant-baseline/main.tf @@ -0,0 +1,102 @@ +################################################################################ +# Tenant Baseline Module +# +# Composite module that provisions a complete tenant environment: +# - Tenant IAM roles with permissions boundary +# - Tenant budget alerts +# - Tenant VPC (optional) +# - Standard tagging +################################################################################ + +terraform { + required_version = ">= 1.5.0" + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 5.0" + } + } +} + +data "aws_caller_identity" "current" {} + +locals { + account_id = data.aws_caller_identity.current.account_id + + # Standard tenant tags + tenant_tags = merge(var.tags, { + Tenant = var.tenant_name + TenantId = var.tenant_id + Environment = var.environment + CostCenter = var.cost_center + Owner = var.owner_email + ManagedBy = "terraform" + }) +} + +################################################################################ +# Tenant IAM +################################################################################ + +module "tenant_iam" { + source = "../tenant-iam" + + tenant_name = var.tenant_name + tenant_id = var.tenant_id + + create_permissions_boundary = var.create_permissions_boundary + create_admin_role = var.create_admin_role + create_developer_role = var.create_developer_role + create_readonly_role = var.create_readonly_role + + trusted_principals = var.trusted_principals + allowed_services = var.allowed_services + require_mfa = var.require_mfa + + tags = local.tenant_tags +} + +################################################################################ +# Tenant Budget +################################################################################ + +module "tenant_budget" { + source = "../tenant-budget" + + name = var.tenant_name + budget_limit = var.budget_limit + + alert_thresholds = var.budget_alert_thresholds + enable_forecasted_alerts = var.enable_forecasted_alerts + notification_emails = var.budget_notification_emails + + cost_filter_tags = { + Tenant = var.tenant_name + } + + tags = local.tenant_tags +} + +################################################################################ +# Tenant VPC (Optional) +################################################################################ + +module "tenant_vpc" { + source = "../tenant-vpc" + count = var.create_vpc ? 1 : 0 + + tenant_name = var.tenant_name + cidr = var.vpc_cidr + azs = var.vpc_azs + + public_subnets = var.vpc_public_subnets + private_subnets = var.vpc_private_subnets + + enable_nat = var.vpc_enable_nat + nat_mode = var.vpc_nat_mode + + transit_gateway_id = var.transit_gateway_id + enable_flow_logs = var.enable_flow_logs + + tags = local.tenant_tags +} diff --git a/terraform/modules/tenant-baseline/outputs.tf b/terraform/modules/tenant-baseline/outputs.tf new file mode 100644 index 0000000..c101699 --- /dev/null +++ b/terraform/modules/tenant-baseline/outputs.tf @@ -0,0 +1,72 @@ +################################################################################ +# Tenant Baseline - Outputs +################################################################################ + +# IAM Outputs +output "permissions_boundary_arn" { + value = module.tenant_iam.permissions_boundary_arn + description = "Permissions boundary ARN" +} + +output "admin_role_arn" { + value = module.tenant_iam.admin_role_arn + description = "Tenant admin role ARN" +} + +output "developer_role_arn" { + value = module.tenant_iam.developer_role_arn + description = "Tenant developer role ARN" +} + +output "readonly_role_arn" { + value = module.tenant_iam.readonly_role_arn + description = "Tenant readonly role ARN" +} + +output "all_role_arns" { + value = module.tenant_iam.all_role_arns + description = "Map of all tenant role ARNs" +} + +# Budget Outputs +output "budget_id" { + value = module.tenant_budget.budget_id + description = "Budget ID" +} + +output "budget_sns_topic_arn" { + value = module.tenant_budget.sns_topic_arn + description = "Budget alerts SNS topic ARN" +} + +# VPC Outputs +output "vpc_id" { + value = var.create_vpc ? module.tenant_vpc[0].vpc_id : null + description = "VPC ID (if created)" +} + +output "vpc_cidr" { + value = var.create_vpc ? module.tenant_vpc[0].vpc_cidr : null + description = "VPC CIDR (if created)" +} + +output "private_subnet_ids" { + value = var.create_vpc ? module.tenant_vpc[0].private_subnet_ids : [] + description = "Private subnet IDs" +} + +output "public_subnet_ids" { + value = var.create_vpc ? module.tenant_vpc[0].public_subnet_ids : [] + description = "Public subnet IDs" +} + +# Summary +output "tenant_tags" { + value = local.tenant_tags + description = "Standard tenant tags" +} + +output "resource_prefix" { + value = module.tenant_iam.resource_prefix + description = "Tenant resource prefix" +} diff --git a/terraform/modules/tenant-baseline/variables.tf b/terraform/modules/tenant-baseline/variables.tf new file mode 100644 index 0000000..8aa2639 --- /dev/null +++ b/terraform/modules/tenant-baseline/variables.tf @@ -0,0 +1,158 @@ +################################################################################ +# Tenant Baseline - Input Variables +################################################################################ + +# Core tenant info +variable "tenant_name" { + type = string + description = "Tenant name (human readable)" +} + +variable "tenant_id" { + type = string + description = "Short tenant ID for resource naming" +} + +variable "environment" { + type = string + description = "Environment (dev, staging, prod)" +} + +variable "cost_center" { + type = string + description = "Cost center for billing" +} + +variable "owner_email" { + type = string + description = "Tenant owner email for notifications" +} + +# IAM Configuration +variable "create_permissions_boundary" { + type = bool + default = true + description = "Create permissions boundary" +} + +variable "create_admin_role" { + type = bool + default = true + description = "Create tenant admin role" +} + +variable "create_developer_role" { + type = bool + default = true + description = "Create tenant developer role" +} + +variable "create_readonly_role" { + type = bool + default = true + description = "Create tenant readonly role" +} + +variable "trusted_principals" { + type = list(string) + default = [] + description = "ARNs allowed to assume tenant roles" +} + +variable "allowed_services" { + type = list(string) + default = ["ec2", "s3", "lambda", "dynamodb", "rds", "ecs", "ecr"] + description = "AWS services the tenant can use" +} + +variable "require_mfa" { + type = bool + default = true + description = "Require MFA for admin role" +} + +# Budget Configuration +variable "budget_limit" { + type = number + default = 100 + description = "Monthly budget limit in USD" +} + +variable "budget_alert_thresholds" { + type = list(number) + default = [50, 80, 100] + description = "Budget alert thresholds" +} + +variable "enable_forecasted_alerts" { + type = bool + default = true + description = "Enable forecasted spend alerts" +} + +variable "budget_notification_emails" { + type = list(string) + default = [] + description = "Email addresses for budget alerts" +} + +# VPC Configuration +variable "create_vpc" { + type = bool + default = false + description = "Create dedicated tenant VPC" +} + +variable "vpc_cidr" { + type = string + default = "10.0.0.0/16" + description = "VPC CIDR block" +} + +variable "vpc_azs" { + type = list(string) + default = [] + description = "Availability zones" +} + +variable "vpc_public_subnets" { + type = list(string) + default = [] + description = "Public subnet CIDRs" +} + +variable "vpc_private_subnets" { + type = list(string) + default = [] + description = "Private subnet CIDRs" +} + +variable "vpc_enable_nat" { + type = bool + default = true + description = "Enable NAT for VPC" +} + +variable "vpc_nat_mode" { + type = string + default = "instance" + description = "NAT mode: gateway or instance" +} + +variable "transit_gateway_id" { + type = string + default = "" + description = "Transit Gateway ID for attachment" +} + +variable "enable_flow_logs" { + type = bool + default = true + description = "Enable VPC flow logs" +} + +variable "tags" { + type = map(string) + default = {} + description = "Additional tags" +} diff --git a/terraform/modules/tenant-budget/README.md b/terraform/modules/tenant-budget/README.md new file mode 100644 index 0000000..c1ae810 --- /dev/null +++ b/terraform/modules/tenant-budget/README.md @@ -0,0 +1,38 @@ +# tenant-budget + +Terraform module for AWS landing zone pattern. + +Create tenant-specific AWS budget alerts. + +## Planned Features + +- [ ] Monthly budget with configurable limit +- [ ] Multi-threshold alerts (50%, 80%, 100%, 120%) +- [ ] Cost allocation tag filtering +- [ ] SNS and email notifications +- [ ] Forecasted spend alerts +- [ ] Auto-actions at budget limits (optional) + +## Planned Usage + +```hcl +module "tenant_budget" { + source = "../modules/tenant-budget" + + tenant_name = "acme-corp" + budget_limit = 500 + + alert_thresholds = [50, 80, 100] + + notification_emails = [ + "billing@acme.com", + "admin@acme.com" + ] + + cost_filter_tags = { + Tenant = "acme-corp" + } + + enable_forecasted_alerts = true +} +``` diff --git a/terraform/modules/tenant-budget/main.tf b/terraform/modules/tenant-budget/main.tf new file mode 100644 index 0000000..ec2c405 --- /dev/null +++ b/terraform/modules/tenant-budget/main.tf @@ -0,0 +1,144 @@ +################################################################################ +# Tenant Budget Module +# +# Creates tenant-specific budget with alerts: +# - Monthly budget with configurable limit +# - Multi-threshold alerts +# - Cost allocation tag filtering +# - SNS and email notifications +################################################################################ + +terraform { + required_version = ">= 1.5.0" + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 5.0" + } + } +} + +data "aws_caller_identity" "current" {} + +locals { + account_id = data.aws_caller_identity.current.account_id + + # Build cost filters from tags + cost_filters = length(var.cost_filter_tags) > 0 ? { + TagKeyValue = [for k, v in var.cost_filter_tags : "user:${k}$${v}"] + } : {} +} + +################################################################################ +# SNS Topic for Budget Alerts +################################################################################ + +resource "aws_sns_topic" "budget" { + count = var.create_sns_topic ? 1 : 0 + + name = "${var.name}-budget-alerts" + + tags = merge(var.tags, { + Name = "${var.name}-budget-alerts" + }) +} + +resource "aws_sns_topic_policy" "budget" { + count = var.create_sns_topic ? 1 : 0 + + arn = aws_sns_topic.budget[0].arn + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Sid = "AllowBudgetsPublish" + Effect = "Allow" + Principal = { + Service = "budgets.amazonaws.com" + } + Action = "sns:Publish" + Resource = aws_sns_topic.budget[0].arn + Condition = { + StringEquals = { + "aws:SourceAccount" = local.account_id + } + } + } + ] + }) +} + +resource "aws_sns_topic_subscription" "email" { + for_each = var.create_sns_topic ? toset(var.notification_emails) : [] + + topic_arn = aws_sns_topic.budget[0].arn + protocol = "email" + endpoint = each.value +} + +################################################################################ +# Budget +################################################################################ + +resource "aws_budgets_budget" "this" { + name = "${var.name}-monthly-budget" + budget_type = "COST" + limit_amount = tostring(var.budget_limit) + limit_unit = "USD" + time_unit = "MONTHLY" + + # Optional: Filter by cost allocation tags + dynamic "cost_filter" { + for_each = local.cost_filters + content { + name = cost_filter.key + values = cost_filter.value + } + } + + cost_types { + include_credit = false + include_discount = true + include_other_subscription = true + include_recurring = true + include_refund = false + include_subscription = true + include_support = true + include_tax = true + include_upfront = true + use_amortized = false + use_blended = false + } + + # Actual spend notifications + dynamic "notification" { + for_each = var.alert_thresholds + content { + comparison_operator = "GREATER_THAN" + threshold = notification.value + threshold_type = "PERCENTAGE" + notification_type = "ACTUAL" + subscriber_email_addresses = var.create_sns_topic ? [] : var.notification_emails + subscriber_sns_topic_arns = var.create_sns_topic ? [aws_sns_topic.budget[0].arn] : (var.sns_topic_arn != null ? [var.sns_topic_arn] : []) + } + } + + # Forecasted spend notifications + dynamic "notification" { + for_each = var.enable_forecasted_alerts ? var.forecasted_thresholds : [] + content { + comparison_operator = "GREATER_THAN" + threshold = notification.value + threshold_type = "PERCENTAGE" + notification_type = "FORECASTED" + subscriber_email_addresses = var.create_sns_topic ? [] : var.notification_emails + subscriber_sns_topic_arns = var.create_sns_topic ? [aws_sns_topic.budget[0].arn] : (var.sns_topic_arn != null ? [var.sns_topic_arn] : []) + } + } + + tags = merge(var.tags, { + Name = "${var.name}-monthly-budget" + Tenant = var.name + }) +} diff --git a/terraform/modules/tenant-budget/outputs.tf b/terraform/modules/tenant-budget/outputs.tf new file mode 100644 index 0000000..819bf65 --- /dev/null +++ b/terraform/modules/tenant-budget/outputs.tf @@ -0,0 +1,28 @@ +################################################################################ +# Tenant Budget - Outputs +################################################################################ + +output "budget_id" { + value = aws_budgets_budget.this.id + description = "Budget ID" +} + +output "budget_name" { + value = aws_budgets_budget.this.name + description = "Budget name" +} + +output "budget_limit" { + value = var.budget_limit + description = "Budget limit in USD" +} + +output "sns_topic_arn" { + value = var.create_sns_topic ? aws_sns_topic.budget[0].arn : var.sns_topic_arn + description = "SNS topic ARN for budget alerts" +} + +output "alert_thresholds" { + value = var.alert_thresholds + description = "Configured alert thresholds" +} diff --git a/terraform/modules/tenant-budget/variables.tf b/terraform/modules/tenant-budget/variables.tf new file mode 100644 index 0000000..3d5e0c3 --- /dev/null +++ b/terraform/modules/tenant-budget/variables.tf @@ -0,0 +1,61 @@ +################################################################################ +# Tenant Budget - Input Variables +################################################################################ + +variable "name" { + type = string + description = "Tenant/budget name" +} + +variable "budget_limit" { + type = number + description = "Monthly budget limit in USD" +} + +variable "alert_thresholds" { + type = list(number) + default = [50, 80, 100] + description = "Percentage thresholds for actual spend alerts" +} + +variable "enable_forecasted_alerts" { + type = bool + default = true + description = "Enable forecasted spend alerts" +} + +variable "forecasted_thresholds" { + type = list(number) + default = [100] + description = "Percentage thresholds for forecasted spend alerts" +} + +variable "notification_emails" { + type = list(string) + default = [] + description = "Email addresses for budget alerts" +} + +variable "create_sns_topic" { + type = bool + default = true + description = "Create SNS topic for alerts" +} + +variable "sns_topic_arn" { + type = string + default = null + description = "Existing SNS topic ARN (if not creating)" +} + +variable "cost_filter_tags" { + type = map(string) + default = {} + description = "Cost allocation tags to filter by" +} + +variable "tags" { + type = map(string) + default = {} + description = "Tags to apply to resources" +} diff --git a/terraform/modules/tenant-iam/README.md b/terraform/modules/tenant-iam/README.md new file mode 100644 index 0000000..dabf35f --- /dev/null +++ b/terraform/modules/tenant-iam/README.md @@ -0,0 +1,45 @@ +# tenant-iam + +Terraform module for AWS landing zone pattern. + +Create tenant-specific IAM roles with proper isolation. + +## Planned Features + +- [ ] Tenant admin role (full tenant access) +- [ ] Tenant developer role (limited write) +- [ ] Tenant readonly role (view only) +- [ ] Permissions boundary enforcement +- [ ] Resource-based isolation (tenant prefix) +- [ ] Cross-account trust configuration + +## Planned Usage + +```hcl +module "tenant_iam" { + source = "../modules/tenant-iam" + + tenant_name = "acme-corp" + tenant_id = "acme" + + create_admin_role = true + create_developer_role = true + create_readonly_role = true + + trusted_principals = [ + "arn:aws:iam::111111111111:root" # Identity account + ] + + allowed_services = ["ec2", "s3", "lambda", "rds"] + resource_prefix = "acme-" + + permissions_boundary = aws_iam_policy.tenant_boundary.arn +} +``` + +## Security + +All tenant roles are created with permissions boundaries to prevent: +- Creating IAM users/roles without boundaries +- Accessing other tenants' resources +- Modifying security services diff --git a/terraform/modules/tenant-iam/main.tf b/terraform/modules/tenant-iam/main.tf new file mode 100644 index 0000000..b001547 --- /dev/null +++ b/terraform/modules/tenant-iam/main.tf @@ -0,0 +1,279 @@ +################################################################################ +# Tenant IAM Module +# +# Creates tenant-specific IAM roles with isolation: +# - Tenant admin role with permissions boundary +# - Tenant developer role +# - Tenant readonly role +# - Permissions boundary for tenant isolation +################################################################################ + +terraform { + required_version = ">= 1.5.0" + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 5.0" + } + } +} + +data "aws_caller_identity" "current" {} +data "aws_partition" "current" {} + +locals { + account_id = data.aws_caller_identity.current.account_id + partition = data.aws_partition.current.partition + + # Resource prefix for tenant isolation + resource_prefix = var.resource_prefix != "" ? var.resource_prefix : "${var.tenant_id}-" +} + +################################################################################ +# Permissions Boundary +################################################################################ + +resource "aws_iam_policy" "boundary" { + count = var.create_permissions_boundary ? 1 : 0 + + name = "${var.tenant_id}-permissions-boundary" + path = var.iam_path + description = "Permissions boundary for ${var.tenant_name} tenant" + + policy = jsonencode({ + Version = "2012-10-17" + Statement = concat( + # Allow specified services + [{ + Sid = "AllowedServices" + Effect = "Allow" + Action = [for svc in var.allowed_services : "${svc}:*"] + Resource = "*" + Condition = { + StringLikeIfExists = { + "aws:ResourceTag/Tenant" = [var.tenant_id, var.tenant_name] + } + } + }], + # Restrict to tenant-prefixed resources where possible + [{ + Sid = "RestrictToTenantResources" + Effect = "Allow" + Action = [ + "s3:*", + "dynamodb:*", + "lambda:*", + "sqs:*", + "sns:*" + ] + Resource = [ + "arn:${local.partition}:s3:::${local.resource_prefix}*", + "arn:${local.partition}:dynamodb:*:${local.account_id}:table/${local.resource_prefix}*", + "arn:${local.partition}:lambda:*:${local.account_id}:function:${local.resource_prefix}*", + "arn:${local.partition}:sqs:*:${local.account_id}:${local.resource_prefix}*", + "arn:${local.partition}:sns:*:${local.account_id}:${local.resource_prefix}*" + ] + }], + # Deny modifying boundary or escalating privileges + [{ + Sid = "DenyBoundaryModification" + Effect = "Deny" + Action = [ + "iam:DeletePolicy", + "iam:DeletePolicyVersion", + "iam:CreatePolicyVersion", + "iam:SetDefaultPolicyVersion" + ] + Resource = "arn:${local.partition}:iam::${local.account_id}:policy/${var.tenant_id}-permissions-boundary" + }], + # Deny creating roles/users without boundary + [{ + Sid = "DenyCreatingRolesWithoutBoundary" + Effect = "Deny" + Action = [ + "iam:CreateRole", + "iam:CreateUser" + ] + Resource = "*" + Condition = { + StringNotEquals = { + "iam:PermissionsBoundary" = "arn:${local.partition}:iam::${local.account_id}:policy/${var.tenant_id}-permissions-boundary" + } + } + }], + # Deny modifying other tenants' resources + [{ + Sid = "DenyAccessToOtherTenants" + Effect = "Deny" + Action = "*" + Resource = "*" + Condition = { + StringNotLike = { + "aws:ResourceTag/Tenant" = [var.tenant_id, var.tenant_name, ""] + } + Null = { + "aws:ResourceTag/Tenant" = "false" + } + } + }], + # Deny disabling security services + [{ + Sid = "DenySecurityServiceModification" + Effect = "Deny" + Action = [ + "guardduty:DeleteDetector", + "guardduty:DisassociateFromMasterAccount", + "securityhub:DisableSecurityHub", + "config:DeleteConfigurationRecorder", + "config:StopConfigurationRecorder", + "cloudtrail:DeleteTrail", + "cloudtrail:StopLogging" + ] + Resource = "*" + }] + ) + }) + + tags = merge(var.tags, { + Name = "${var.tenant_id}-permissions-boundary" + Tenant = var.tenant_name + }) +} + +################################################################################ +# Admin Role +################################################################################ + +resource "aws_iam_role" "admin" { + count = var.create_admin_role ? 1 : 0 + + name = "${var.tenant_id}-admin" + path = var.iam_path + permissions_boundary = var.create_permissions_boundary ? aws_iam_policy.boundary[0].arn : var.permissions_boundary_arn + max_session_duration = var.admin_session_duration + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Action = "sts:AssumeRole" + Effect = "Allow" + Principal = { + AWS = var.trusted_principals + } + Condition = var.require_mfa ? { + Bool = { + "aws:MultiFactorAuthPresent" = "true" + } + } : {} + }] + }) + + tags = merge(var.tags, { + Name = "${var.tenant_id}-admin" + Tenant = var.tenant_name + Role = "admin" + }) +} + +resource "aws_iam_role_policy_attachment" "admin" { + count = var.create_admin_role ? 1 : 0 + + role = aws_iam_role.admin[0].name + policy_arn = "arn:${local.partition}:iam::aws:policy/PowerUserAccess" +} + +################################################################################ +# Developer Role +################################################################################ + +resource "aws_iam_role" "developer" { + count = var.create_developer_role ? 1 : 0 + + name = "${var.tenant_id}-developer" + path = var.iam_path + permissions_boundary = var.create_permissions_boundary ? aws_iam_policy.boundary[0].arn : var.permissions_boundary_arn + max_session_duration = var.developer_session_duration + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Action = "sts:AssumeRole" + Effect = "Allow" + Principal = { + AWS = var.trusted_principals + } + }] + }) + + tags = merge(var.tags, { + Name = "${var.tenant_id}-developer" + Tenant = var.tenant_name + Role = "developer" + }) +} + +resource "aws_iam_role_policy" "developer" { + count = var.create_developer_role ? 1 : 0 + + name = "developer-access" + role = aws_iam_role.developer[0].id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Sid = "DeveloperAccess" + Effect = "Allow" + Action = [for svc in var.allowed_services : "${svc}:*"] + Resource = "*" + }, + { + Sid = "DenyAdmin" + Effect = "Deny" + Action = [ + "iam:*", + "organizations:*", + "account:*" + ] + Resource = "*" + } + ] + }) +} + +################################################################################ +# Readonly Role +################################################################################ + +resource "aws_iam_role" "readonly" { + count = var.create_readonly_role ? 1 : 0 + + name = "${var.tenant_id}-readonly" + path = var.iam_path + permissions_boundary = var.create_permissions_boundary ? aws_iam_policy.boundary[0].arn : var.permissions_boundary_arn + max_session_duration = var.readonly_session_duration + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Action = "sts:AssumeRole" + Effect = "Allow" + Principal = { + AWS = var.trusted_principals + } + }] + }) + + tags = merge(var.tags, { + Name = "${var.tenant_id}-readonly" + Tenant = var.tenant_name + Role = "readonly" + }) +} + +resource "aws_iam_role_policy_attachment" "readonly" { + count = var.create_readonly_role ? 1 : 0 + + role = aws_iam_role.readonly[0].name + policy_arn = "arn:${local.partition}:iam::aws:policy/ReadOnlyAccess" +} diff --git a/terraform/modules/tenant-iam/outputs.tf b/terraform/modules/tenant-iam/outputs.tf new file mode 100644 index 0000000..072da84 --- /dev/null +++ b/terraform/modules/tenant-iam/outputs.tf @@ -0,0 +1,52 @@ +################################################################################ +# Tenant IAM - Outputs +################################################################################ + +output "permissions_boundary_arn" { + value = var.create_permissions_boundary ? aws_iam_policy.boundary[0].arn : var.permissions_boundary_arn + description = "Permissions boundary policy ARN" +} + +output "admin_role_arn" { + value = try(aws_iam_role.admin[0].arn, null) + description = "Tenant admin role ARN" +} + +output "admin_role_name" { + value = try(aws_iam_role.admin[0].name, null) + description = "Tenant admin role name" +} + +output "developer_role_arn" { + value = try(aws_iam_role.developer[0].arn, null) + description = "Tenant developer role ARN" +} + +output "developer_role_name" { + value = try(aws_iam_role.developer[0].name, null) + description = "Tenant developer role name" +} + +output "readonly_role_arn" { + value = try(aws_iam_role.readonly[0].arn, null) + description = "Tenant readonly role ARN" +} + +output "readonly_role_name" { + value = try(aws_iam_role.readonly[0].name, null) + description = "Tenant readonly role name" +} + +output "all_role_arns" { + value = { + admin = try(aws_iam_role.admin[0].arn, null) + developer = try(aws_iam_role.developer[0].arn, null) + readonly = try(aws_iam_role.readonly[0].arn, null) + } + description = "Map of all tenant role ARNs" +} + +output "resource_prefix" { + value = local.resource_prefix + description = "Resource prefix for tenant naming" +} diff --git a/terraform/modules/tenant-iam/variables.tf b/terraform/modules/tenant-iam/variables.tf new file mode 100644 index 0000000..71c4392 --- /dev/null +++ b/terraform/modules/tenant-iam/variables.tf @@ -0,0 +1,97 @@ +################################################################################ +# Tenant IAM - Input Variables +################################################################################ + +variable "tenant_name" { + type = string + description = "Tenant name (human readable)" +} + +variable "tenant_id" { + type = string + description = "Short tenant ID for resource naming" +} + +variable "create_permissions_boundary" { + type = bool + default = true + description = "Create permissions boundary policy" +} + +variable "permissions_boundary_arn" { + type = string + default = null + description = "Existing permissions boundary ARN (if not creating)" +} + +variable "create_admin_role" { + type = bool + default = true + description = "Create tenant admin role" +} + +variable "create_developer_role" { + type = bool + default = true + description = "Create tenant developer role" +} + +variable "create_readonly_role" { + type = bool + default = true + description = "Create tenant readonly role" +} + +variable "trusted_principals" { + type = list(string) + default = [] + description = "ARNs allowed to assume tenant roles" +} + +variable "allowed_services" { + type = list(string) + default = ["ec2", "s3", "lambda", "dynamodb", "rds", "ecs", "ecr", "logs", "cloudwatch", "events", "sqs", "sns"] + description = "AWS services the tenant can use" +} + +variable "resource_prefix" { + type = string + default = "" + description = "Resource naming prefix (defaults to tenant_id-)" +} + +variable "iam_path" { + type = string + default = "/tenants/" + description = "IAM path for roles and policies" +} + +variable "require_mfa" { + type = bool + default = true + description = "Require MFA for admin role" +} + +variable "admin_session_duration" { + type = number + default = 3600 + description = "Admin role session duration in seconds" +} + +variable "developer_session_duration" { + type = number + default = 14400 + description = "Developer role session duration in seconds" +} + +variable "readonly_session_duration" { + type = number + default = 14400 + description = "Readonly role session duration in seconds" +} + +variable "tags" { + type = map(string) + default = {} + description = "Tags to apply to resources" +} diff --git a/terraform/modules/tenant-onboard/README.md b/terraform/modules/tenant-onboard/README.md new file mode 100644 index 0000000..242ffab --- /dev/null +++ b/terraform/modules/tenant-onboard/README.md @@ -0,0 +1,60 @@ +# tenant-onboard + +Tenant Onboarding Module Creates: Tenant OUs, App Accounts, IAM Groups, Budgets } + +## Usage + +```hcl +module "tenant_onboard" { + source = "../modules/tenant-onboard" + + # Required variables + tenant = "" + email_domain = "" + production_ou_id = "" + nonproduction_ou_id = "" + apps = "" + alert_emails = "" + permission_set_admin_arn = "" + permission_set_developer_arn = "" + permission_set_readonly_arn = "" + + # Optional: see variables.tf for all options +} +``` + +## Requirements + +| Name | Version | +|------|---------| +| terraform | >= 1.5.0 | +| aws | >= 5.0 | + +## Inputs + +| Name | Description | Type | Required | +|------|-------------|------|----------| +| tenant | Tenant identifier (lowercase, no spaces) | `string` | yes | +| email_domain | Domain for AWS account emails | `string` | yes | +| email_prefix | Email prefix before + sign | `string` | no | +| production_ou_id | ID of the Production OU | `string` | yes | +| nonproduction_ou_id | ID of the Non-Production OU | `string` | yes | +| environments | Environments to create for each app | `list(string)` | no | +| apps | Map of applications for this tenant | `map(object({` | yes | +| monthly_budget | Total monthly budget for tenant | `number` | no | +| alert_emails | Emails to receive budget alerts | `list(string)` | yes | +| permission_set_admin_arn | ARN of the TenantAdmin permission set | `string` | yes | +| permission_set_developer_arn | ARN of the TenantDeveloper permission set | `string` | yes | +| permission_set_readonly_arn | ARN of the TenantReadOnly permission set | `string` | yes | + +## Outputs + +| Name | Description | +|------|-------------| +| tenant_ou_ids | | +| account_ids | | +| group_ids | | + +## License + +Apache 2.0 - See LICENSE for details. diff --git a/terraform/modules/tenant-onboard/main.tf b/terraform/modules/tenant-onboard/main.tf new file mode 100644 index 0000000..69fe643 --- /dev/null +++ b/terraform/modules/tenant-onboard/main.tf @@ -0,0 +1,243 @@ +################################################################################ +# Tenant Onboarding Module +# Creates: Tenant OUs, App Accounts, IAM Groups, Budgets +################################################################################ + +terraform { + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 5.0" + } + } +} + +################################################################################ +# Data Sources +################################################################################ + +data "aws_organizations_organization" "main" {} + +data "aws_ssoadmin_instances" "main" {} + +################################################################################ +# Tenant OUs +################################################################################ + +resource "aws_organizations_organizational_unit" "tenant_prod" { + name = "tenant-${var.tenant}-prod" + parent_id = var.production_ou_id +} + +resource "aws_organizations_organizational_unit" "tenant_nonprod" { + name = "tenant-${var.tenant}-nonprod" + parent_id = var.nonproduction_ou_id +} + +################################################################################ +# App Accounts +################################################################################ + +locals { + # Generate all app/environment combinations + app_accounts = { + for combo in setproduct(keys(var.apps), var.environments) : + "${combo[0]}-${combo[1]}" => { + app = combo[0] + env = combo[1] + } + } +} + +resource "aws_organizations_account" "app" { + for_each = local.app_accounts + + name = "${var.tenant}-${each.value.app}-${each.value.env}" + email = "${var.email_prefix}+${var.tenant}-${each.value.app}-${each.value.env}@${var.email_domain}" + role_name = "OrganizationAccountAccessRole" + + parent_id = each.value.env == "prod" ? ( + aws_organizations_organizational_unit.tenant_prod.id + ) : ( + aws_organizations_organizational_unit.tenant_nonprod.id + ) + + tags = { + Tenant = var.tenant + App = each.value.app + Environment = each.value.env + ManagedBy = "terraform" + } + + lifecycle { + ignore_changes = [role_name] + } +} + +################################################################################ +# IAM Identity Center - Tenant Group +################################################################################ + +resource "aws_identitystore_group" "tenant" { + identity_store_id = tolist(data.aws_ssoadmin_instances.main.identity_store_ids)[0] + display_name = "Tenant-${var.tenant}" + description = "All users for tenant ${var.tenant}" +} + +# Create role-specific groups +resource "aws_identitystore_group" "tenant_admins" { + identity_store_id = tolist(data.aws_ssoadmin_instances.main.identity_store_ids)[0] + display_name = "Tenant-${var.tenant}-Admins" + description = "Admins for tenant ${var.tenant}" +} + +resource "aws_identitystore_group" "tenant_developers" { + identity_store_id = tolist(data.aws_ssoadmin_instances.main.identity_store_ids)[0] + display_name = "Tenant-${var.tenant}-Developers" + description = "Developers for tenant ${var.tenant}" +} + +resource "aws_identitystore_group" "tenant_readonly" { + identity_store_id = tolist(data.aws_ssoadmin_instances.main.identity_store_ids)[0] + display_name = "Tenant-${var.tenant}-ReadOnly" + description = "Read-only users for tenant ${var.tenant}" +} + +################################################################################ +# Account Assignments - Admin access to all tenant accounts +################################################################################ + +resource "aws_ssoadmin_account_assignment" "admin" { + for_each = aws_organizations_account.app + + instance_arn = tolist(data.aws_ssoadmin_instances.main.arns)[0] + permission_set_arn = var.permission_set_admin_arn + + principal_id = aws_identitystore_group.tenant_admins.group_id + principal_type = "GROUP" + + target_id = each.value.id + target_type = "AWS_ACCOUNT" +} + +# Developer access to non-prod only +resource "aws_ssoadmin_account_assignment" "developer" { + for_each = { + for k, v in aws_organizations_account.app : k => v + if local.app_accounts[k].env != "prod" + } + + instance_arn = tolist(data.aws_ssoadmin_instances.main.arns)[0] + permission_set_arn = var.permission_set_developer_arn + + principal_id = aws_identitystore_group.tenant_developers.group_id + principal_type = "GROUP" + + target_id = each.value.id + target_type = "AWS_ACCOUNT" +} + +# Read-only access to all accounts +resource "aws_ssoadmin_account_assignment" "readonly" { + for_each = aws_organizations_account.app + + instance_arn = tolist(data.aws_ssoadmin_instances.main.arns)[0] + permission_set_arn = var.permission_set_readonly_arn + + principal_id = aws_identitystore_group.tenant_readonly.group_id + principal_type = "GROUP" + + target_id = each.value.id + target_type = "AWS_ACCOUNT" +} + +################################################################################ +# Budgets +################################################################################ + +resource "aws_budgets_budget" "tenant" { + name = "${var.tenant}-monthly-budget" + budget_type = "COST" + limit_amount = var.monthly_budget + limit_unit = "USD" + time_unit = "MONTHLY" + + cost_filter { + name = "TagKeyValue" + values = ["Tenant$${var.tenant}"] + } + + notification { + comparison_operator = "GREATER_THAN" + notification_type = "ACTUAL" + threshold = 50 + threshold_type = "PERCENTAGE" + subscriber_email_addresses = var.alert_emails + } + + notification { + comparison_operator = "GREATER_THAN" + notification_type = "ACTUAL" + threshold = 80 + threshold_type = "PERCENTAGE" + subscriber_email_addresses = var.alert_emails + } + + notification { + comparison_operator = "GREATER_THAN" + notification_type = "FORECASTED" + threshold = 100 + threshold_type = "PERCENTAGE" + subscriber_email_addresses = var.alert_emails + } +} + +# Per-app budgets +resource "aws_budgets_budget" "app" { + for_each = var.apps + + name = "${var.tenant}-${each.key}-budget" + budget_type = "COST" + limit_amount = each.value.monthly_budget + limit_unit = "USD" + time_unit = "MONTHLY" + + cost_filter { + name = "TagKeyValue" + values = ["App$${each.key}"] + } + + notification { + comparison_operator = "GREATER_THAN" + notification_type = "ACTUAL" + threshold = 80 + threshold_type = "PERCENTAGE" + subscriber_email_addresses = [each.value.owner_email] + } +} + +################################################################################ +# Outputs +################################################################################ + +output "tenant_ou_ids" { + value = { + prod = aws_organizations_organizational_unit.tenant_prod.id + nonprod = aws_organizations_organizational_unit.tenant_nonprod.id + } +} + +output "account_ids" { + value = { + for k, v in aws_organizations_account.app : k => v.id + } +} + +output "group_ids" { + value = { + all = aws_identitystore_group.tenant.group_id + admins = aws_identitystore_group.tenant_admins.group_id + developers = aws_identitystore_group.tenant_developers.group_id + readonly = aws_identitystore_group.tenant_readonly.group_id + } +} diff --git a/terraform/modules/tenant-onboard/variables.tf b/terraform/modules/tenant-onboard/variables.tf new file mode 100644 index 0000000..230c9ab --- /dev/null +++ b/terraform/modules/tenant-onboard/variables.tf @@ -0,0 +1,70 @@ +variable "tenant" { + description = "Tenant identifier (lowercase, no spaces)" + type = string + + validation { + condition = can(regex("^[a-z0-9-]+$", var.tenant)) + error_message = "Tenant must be lowercase alphanumeric with hyphens only." + } +} + +variable "email_domain" { + description = "Domain for AWS account emails" + type = string +} + +variable "email_prefix" { + description = "Email prefix before + sign" + type = string + default = "aws" +} + +variable "production_ou_id" { + description = "ID of the Production OU" + type = string +} + +variable "nonproduction_ou_id" { + description = "ID of the Non-Production OU" + type = string +} + +variable "environments" { + description = "Environments to create for each app" + type = list(string) + default = ["prod", "staging", "dev"] +} + +variable "apps" { + description = "Map of applications for this tenant" + type = map(object({ + monthly_budget = number + owner_email = string + })) +} + +variable "monthly_budget" { + description = "Total monthly budget for tenant" + type = number + default = 1000 +} + +variable "alert_emails" { + description = "Emails to receive budget alerts" + type = list(string) +} + +variable "permission_set_admin_arn" { + description = "ARN of the TenantAdmin permission set" + type = string +} + +variable "permission_set_developer_arn" { + description = "ARN of the TenantDeveloper permission set" + type = string +} + +variable "permission_set_readonly_arn" { + description = "ARN of the TenantReadOnly permission set" + type = string +} diff --git a/terraform/modules/tenant-security-group/README.md b/terraform/modules/tenant-security-group/README.md new file mode 100644 index 0000000..b6f2ecb --- /dev/null +++ b/terraform/modules/tenant-security-group/README.md @@ -0,0 +1,51 @@ +# tenant-security-group + +Tenant Security Group Module Creates isolated security groups for tenant workloads in shared VPC } + +## Usage + +```hcl +module "tenant_security_group" { + source = "../modules/tenant-security-group" + + # Required variables + tenant = "" + environment = "" + vpc_id = "" + + # Optional: see variables.tf for all options +} +``` + +## Requirements + +| Name | Version | +|------|---------| +| terraform | >= 1.5.0 | +| aws | >= 5.0 | + +## Inputs + +| Name | Description | Type | Required | +|------|-------------|------|----------| +| tenant | Tenant identifier | `string` | yes | +| environment | Environment (prod, staging, dev) | `string` | yes | +| vpc_id | VPC ID for the security groups | `string` | yes | +| create_web_sg | Create web tier security group | `bool` | no | +| create_app_sg | Create app tier security group | `bool` | no | +| create_db_sg | Create database tier security group | `bool` | no | +| app_port | Application port | `number` | no | +| db_port | Database port | `number` | no | + +## Outputs + +| Name | Description | +|------|-------------| +| base_sg_id | | +| web_sg_id | | +| app_sg_id | | +| db_sg_id | | + +## License + +Apache 2.0 - See LICENSE for details. diff --git a/terraform/modules/tenant-security-group/main.tf b/terraform/modules/tenant-security-group/main.tf new file mode 100644 index 0000000..b0429b2 --- /dev/null +++ b/terraform/modules/tenant-security-group/main.tf @@ -0,0 +1,201 @@ +################################################################################ +# Tenant Security Group Module +# Creates isolated security groups for tenant workloads in shared VPC +################################################################################ + +terraform { + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 5.0" + } + } +} + +################################################################################ +# Base Tenant Security Group +################################################################################ + +resource "aws_security_group" "tenant_base" { + name = "${var.tenant}-base-sg" + description = "Base security group for tenant ${var.tenant}" + vpc_id = var.vpc_id + + # Allow all traffic within same tenant (same SG) + ingress { + description = "Allow intra-tenant traffic" + from_port = 0 + to_port = 0 + protocol = "-1" + self = true + } + + # Allow outbound internet + egress { + description = "Allow all outbound" + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + } + + tags = { + Name = "${var.tenant}-base-sg" + Tenant = var.tenant + Environment = var.environment + } +} + +################################################################################ +# Web Tier Security Group +################################################################################ + +resource "aws_security_group" "tenant_web" { + count = var.create_web_sg ? 1 : 0 + + name = "${var.tenant}-web-sg" + description = "Web tier security group for tenant ${var.tenant}" + vpc_id = var.vpc_id + + # HTTPS from anywhere + ingress { + description = "HTTPS from anywhere" + from_port = 443 + to_port = 443 + protocol = "tcp" + cidr_blocks = ["0.0.0.0/0"] + } + + # HTTP from anywhere (redirect to HTTPS) + ingress { + description = "HTTP from anywhere" + from_port = 80 + to_port = 80 + protocol = "tcp" + cidr_blocks = ["0.0.0.0/0"] + } + + # Allow from tenant base SG + ingress { + description = "Allow from tenant base" + from_port = 0 + to_port = 0 + protocol = "-1" + security_groups = [aws_security_group.tenant_base.id] + } + + egress { + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + } + + tags = { + Name = "${var.tenant}-web-sg" + Tenant = var.tenant + Environment = var.environment + Tier = "web" + } +} + +################################################################################ +# App Tier Security Group +################################################################################ + +resource "aws_security_group" "tenant_app" { + count = var.create_app_sg ? 1 : 0 + + name = "${var.tenant}-app-sg" + description = "App tier security group for tenant ${var.tenant}" + vpc_id = var.vpc_id + + # Allow from web tier + ingress { + description = "Allow from web tier" + from_port = var.app_port + to_port = var.app_port + protocol = "tcp" + security_groups = var.create_web_sg ? [aws_security_group.tenant_web[0].id] : [] + } + + # Allow from tenant base SG + ingress { + description = "Allow from tenant base" + from_port = 0 + to_port = 0 + protocol = "-1" + security_groups = [aws_security_group.tenant_base.id] + } + + egress { + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + } + + tags = { + Name = "${var.tenant}-app-sg" + Tenant = var.tenant + Environment = var.environment + Tier = "app" + } +} + +################################################################################ +# Database Tier Security Group +################################################################################ + +resource "aws_security_group" "tenant_db" { + count = var.create_db_sg ? 1 : 0 + + name = "${var.tenant}-db-sg" + description = "Database tier security group for tenant ${var.tenant}" + vpc_id = var.vpc_id + + # Allow from app tier only + ingress { + description = "Allow from app tier" + from_port = var.db_port + to_port = var.db_port + protocol = "tcp" + security_groups = var.create_app_sg ? [aws_security_group.tenant_app[0].id] : [aws_security_group.tenant_base.id] + } + + # No direct outbound (DB shouldn't initiate connections) + egress { + description = "Allow response to app tier" + from_port = 0 + to_port = 0 + protocol = "-1" + security_groups = var.create_app_sg ? [aws_security_group.tenant_app[0].id] : [aws_security_group.tenant_base.id] + } + + tags = { + Name = "${var.tenant}-db-sg" + Tenant = var.tenant + Environment = var.environment + Tier = "database" + } +} + +################################################################################ +# Outputs +################################################################################ + +output "base_sg_id" { + value = aws_security_group.tenant_base.id +} + +output "web_sg_id" { + value = var.create_web_sg ? aws_security_group.tenant_web[0].id : null +} + +output "app_sg_id" { + value = var.create_app_sg ? aws_security_group.tenant_app[0].id : null +} + +output "db_sg_id" { + value = var.create_db_sg ? aws_security_group.tenant_db[0].id : null +} diff --git a/terraform/modules/tenant-security-group/variables.tf b/terraform/modules/tenant-security-group/variables.tf new file mode 100644 index 0000000..6ceb1eb --- /dev/null +++ b/terraform/modules/tenant-security-group/variables.tf @@ -0,0 +1,44 @@ +variable "tenant" { + description = "Tenant identifier" + type = string +} + +variable "environment" { + description = "Environment (prod, staging, dev)" + type = string +} + +variable "vpc_id" { + description = "VPC ID for the security groups" + type = string +} + +variable "create_web_sg" { + description = "Create web tier security group" + type = bool + default = true +} + +variable "create_app_sg" { + description = "Create app tier security group" + type = bool + default = true +} + +variable "create_db_sg" { + description = "Create database tier security group" + type = bool + default = true +} + +variable "app_port" { + description = "Application port" + type = number + default = 8080 +} + +variable "db_port" { + description = "Database port" + type = number + default = 5432 +} diff --git a/terraform/modules/tenant-vpc/README.md b/terraform/modules/tenant-vpc/README.md new file mode 100644 index 0000000..4e59348 --- /dev/null +++ b/terraform/modules/tenant-vpc/README.md @@ -0,0 +1,36 @@ +# tenant-vpc + +Terraform module for AWS landing zone pattern. + +Create tenant-isolated VPC with standard networking. + +## Planned Features + +- [ ] Dedicated CIDR block +- [ ] Public/private subnets across AZs +- [ ] NAT Gateway or cost-optimized NAT Instance +- [ ] VPC Flow Logs to CloudWatch +- [ ] Transit Gateway attachment +- [ ] Routes to shared services VPC + +## Planned Usage + +```hcl +module "tenant_vpc" { + source = "../modules/tenant-vpc" + + tenant_name = "acme-corp" + cidr = "10.100.0.0/16" + azs = ["us-east-1a", "us-east-1b"] + + private_subnets = ["10.100.1.0/24", "10.100.2.0/24"] + public_subnets = ["10.100.101.0/24", "10.100.102.0/24"] + + enable_nat = true + nat_mode = "instance" # Cost-optimized + + transit_gateway_id = data.aws_ec2_transit_gateway.main.id + + tags = local.tags +} +``` diff --git a/terraform/modules/tenant-vpc/main.tf b/terraform/modules/tenant-vpc/main.tf new file mode 100644 index 0000000..ea24b04 --- /dev/null +++ b/terraform/modules/tenant-vpc/main.tf @@ -0,0 +1,333 @@ +################################################################################ +# Tenant VPC Module +# +# Creates tenant-isolated VPC with standard networking: +# - Dedicated CIDR block +# - Public/private subnets +# - NAT Gateway or NAT Instance +# - VPC Flow Logs +# - Optional Transit Gateway attachment +################################################################################ + +terraform { + required_version = ">= 1.5.0" + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 5.0" + } + } +} + +data "aws_caller_identity" "current" {} +data "aws_region" "current" {} + +locals { + account_id = data.aws_caller_identity.current.account_id + region = data.aws_region.current.id + + # Calculate subnets if not explicitly provided + azs = length(var.azs) > 0 ? var.azs : slice(data.aws_availability_zones.available.names, 0, var.az_count) + + # Common tags for VPC resources + vpc_tags = merge(var.tags, { + Tenant = var.tenant_name + }) +} + +data "aws_availability_zones" "available" { + state = "available" +} + +################################################################################ +# VPC +################################################################################ + +resource "aws_vpc" "this" { + cidr_block = var.cidr + enable_dns_hostnames = true + enable_dns_support = true + + tags = merge(local.vpc_tags, { + Name = "${var.tenant_name}-vpc" + }) +} + +################################################################################ +# Internet Gateway +################################################################################ + +resource "aws_internet_gateway" "this" { + count = length(var.public_subnets) > 0 ? 1 : 0 + + vpc_id = aws_vpc.this.id + + tags = merge(local.vpc_tags, { + Name = "${var.tenant_name}-igw" + }) +} + +################################################################################ +# Subnets +################################################################################ + +resource "aws_subnet" "public" { + count = length(var.public_subnets) + + vpc_id = aws_vpc.this.id + cidr_block = var.public_subnets[count.index] + availability_zone = local.azs[count.index % length(local.azs)] + map_public_ip_on_launch = true + + tags = merge(local.vpc_tags, { + Name = "${var.tenant_name}-public-${local.azs[count.index % length(local.azs)]}" + Tier = "public" + }) +} + +resource "aws_subnet" "private" { + count = length(var.private_subnets) + + vpc_id = aws_vpc.this.id + cidr_block = var.private_subnets[count.index] + availability_zone = local.azs[count.index % length(local.azs)] + + tags = merge(local.vpc_tags, { + Name = "${var.tenant_name}-private-${local.azs[count.index % length(local.azs)]}" + Tier = "private" + }) +} + +################################################################################ +# NAT Gateway / Instance +################################################################################ + +resource "aws_eip" "nat" { + count = var.enable_nat && var.nat_mode == "gateway" ? 1 : 0 + + domain = "vpc" + + tags = merge(local.vpc_tags, { + Name = "${var.tenant_name}-nat-eip" + }) + + depends_on = [aws_internet_gateway.this] +} + +resource "aws_nat_gateway" "this" { + count = var.enable_nat && var.nat_mode == "gateway" ? 1 : 0 + + allocation_id = aws_eip.nat[0].id + subnet_id = aws_subnet.public[0].id + + tags = merge(local.vpc_tags, { + Name = "${var.tenant_name}-nat-gateway" + }) + + depends_on = [aws_internet_gateway.this] +} + +# NAT Instance (cost-optimized alternative) +data "aws_ami" "nat" { + count = var.enable_nat && var.nat_mode == "instance" ? 1 : 0 + + most_recent = true + owners = ["amazon"] + + filter { + name = "name" + values = ["amzn-ami-vpc-nat-*"] + } + + filter { + name = "architecture" + values = ["x86_64"] + } +} + +resource "aws_security_group" "nat" { + count = var.enable_nat && var.nat_mode == "instance" ? 1 : 0 + + name_prefix = "${var.tenant_name}-nat-" + description = "NAT instance security group" + vpc_id = aws_vpc.this.id + + ingress { + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = var.private_subnets + } + + egress { + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + } + + tags = merge(local.vpc_tags, { + Name = "${var.tenant_name}-nat-sg" + }) +} + +resource "aws_instance" "nat" { + count = var.enable_nat && var.nat_mode == "instance" ? 1 : 0 + + ami = data.aws_ami.nat[0].id + instance_type = var.nat_instance_type + subnet_id = aws_subnet.public[0].id + vpc_security_group_ids = [aws_security_group.nat[0].id] + source_dest_check = false + associate_public_ip_address = true + + tags = merge(local.vpc_tags, { + Name = "${var.tenant_name}-nat-instance" + }) +} + +################################################################################ +# Route Tables +################################################################################ + +resource "aws_route_table" "public" { + count = length(var.public_subnets) > 0 ? 1 : 0 + + vpc_id = aws_vpc.this.id + + tags = merge(local.vpc_tags, { + Name = "${var.tenant_name}-public-rt" + }) +} + +resource "aws_route" "public_internet" { + count = length(var.public_subnets) > 0 ? 1 : 0 + + route_table_id = aws_route_table.public[0].id + destination_cidr_block = "0.0.0.0/0" + gateway_id = aws_internet_gateway.this[0].id +} + +resource "aws_route_table_association" "public" { + count = length(var.public_subnets) + + subnet_id = aws_subnet.public[count.index].id + route_table_id = aws_route_table.public[0].id +} + +resource "aws_route_table" "private" { + count = length(var.private_subnets) > 0 ? 1 : 0 + + vpc_id = aws_vpc.this.id + + tags = merge(local.vpc_tags, { + Name = "${var.tenant_name}-private-rt" + }) +} + +resource "aws_route" "private_nat_gateway" { + count = var.enable_nat && var.nat_mode == "gateway" && length(var.private_subnets) > 0 ? 1 : 0 + + route_table_id = aws_route_table.private[0].id + destination_cidr_block = "0.0.0.0/0" + nat_gateway_id = aws_nat_gateway.this[0].id +} + +resource "aws_route" "private_nat_instance" { + count = var.enable_nat && var.nat_mode == "instance" && length(var.private_subnets) > 0 ? 1 : 0 + + route_table_id = aws_route_table.private[0].id + destination_cidr_block = "0.0.0.0/0" + network_interface_id = aws_instance.nat[0].primary_network_interface_id +} + +resource "aws_route_table_association" "private" { + count = length(var.private_subnets) + + subnet_id = aws_subnet.private[count.index].id + route_table_id = aws_route_table.private[0].id +} + +################################################################################ +# Transit Gateway Attachment +################################################################################ + +resource "aws_ec2_transit_gateway_vpc_attachment" "this" { + count = var.transit_gateway_id != "" ? 1 : 0 + + transit_gateway_id = var.transit_gateway_id + vpc_id = aws_vpc.this.id + subnet_ids = aws_subnet.private[*].id + + tags = merge(local.vpc_tags, { + Name = "${var.tenant_name}-tgw-attachment" + }) +} + +################################################################################ +# VPC Flow Logs +################################################################################ + +resource "aws_cloudwatch_log_group" "flow_logs" { + count = var.enable_flow_logs ? 1 : 0 + + name = "/aws/vpc/${var.tenant_name}/flow-logs" + retention_in_days = var.flow_log_retention_days + + tags = local.vpc_tags +} + +resource "aws_iam_role" "flow_logs" { + count = var.enable_flow_logs ? 1 : 0 + + name = "${var.tenant_name}-vpc-flow-logs-role" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Action = "sts:AssumeRole" + Effect = "Allow" + Principal = { + Service = "vpc-flow-logs.amazonaws.com" + } + }] + }) + + tags = local.vpc_tags +} + +resource "aws_iam_role_policy" "flow_logs" { + count = var.enable_flow_logs ? 1 : 0 + + name = "flow-logs-policy" + role = aws_iam_role.flow_logs[0].id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Action = [ + "logs:CreateLogStream", + "logs:PutLogEvents", + "logs:DescribeLogGroups", + "logs:DescribeLogStreams" + ] + Effect = "Allow" + Resource = "${aws_cloudwatch_log_group.flow_logs[0].arn}:*" + }] + }) +} + +resource "aws_flow_log" "this" { + count = var.enable_flow_logs ? 1 : 0 + + vpc_id = aws_vpc.this.id + traffic_type = "ALL" + log_destination_type = "cloud-watch-logs" + log_destination = aws_cloudwatch_log_group.flow_logs[0].arn + iam_role_arn = aws_iam_role.flow_logs[0].arn + max_aggregation_interval = 60 + + tags = merge(local.vpc_tags, { + Name = "${var.tenant_name}-vpc-flow-log" + }) +} diff --git a/terraform/modules/tenant-vpc/outputs.tf b/terraform/modules/tenant-vpc/outputs.tf new file mode 100644 index 0000000..bab17bb --- /dev/null +++ b/terraform/modules/tenant-vpc/outputs.tf @@ -0,0 +1,57 @@ +################################################################################ +# Tenant VPC - Outputs +################################################################################ + +output "vpc_id" { + value = aws_vpc.this.id + description = "VPC ID" +} + +output "vpc_cidr" { + value = aws_vpc.this.cidr_block + description = "VPC CIDR block" +} + +output "public_subnet_ids" { + value = aws_subnet.public[*].id + description = "Public subnet IDs" +} + +output "private_subnet_ids" { + value = aws_subnet.private[*].id + description = "Private subnet IDs" +} + +output "public_route_table_id" { + value = try(aws_route_table.public[0].id, null) + description = "Public route table ID" +} + +output "private_route_table_id" { + value = try(aws_route_table.private[0].id, null) + description = "Private route table ID" +} + +output "nat_public_ip" { + value = var.nat_mode == "gateway" ? ( + try(aws_eip.nat[0].public_ip, null) + ) : ( + try(aws_instance.nat[0].public_ip, null) + ) + description = "NAT Gateway/Instance public IP" +} + +output "tgw_attachment_id" { + value = try(aws_ec2_transit_gateway_vpc_attachment.this[0].id, null) + description = "Transit Gateway attachment ID" +} + +output "flow_log_group" { + value = try(aws_cloudwatch_log_group.flow_logs[0].name, null) + description = "Flow log CloudWatch log group" +} + +output "azs" { + value = local.azs + description = "Availability zones used" +} diff --git a/terraform/modules/tenant-vpc/variables.tf b/terraform/modules/tenant-vpc/variables.tf new file mode 100644 index 0000000..f38a865 --- /dev/null +++ b/terraform/modules/tenant-vpc/variables.tf @@ -0,0 +1,83 @@ +################################################################################ +# Tenant VPC - Input Variables +################################################################################ + +variable "tenant_name" { + type = string + description = "Tenant name (used for resource naming)" +} + +variable "cidr" { + type = string + description = "VPC CIDR block" +} + +variable "azs" { + type = list(string) + default = [] + description = "Availability zones (auto-detected if empty)" +} + +variable "az_count" { + type = number + default = 2 + description = "Number of AZs if not specifying azs" +} + +variable "public_subnets" { + type = list(string) + default = [] + description = "Public subnet CIDRs" +} + +variable "private_subnets" { + type = list(string) + description = "Private subnet CIDRs" +} + +variable "enable_nat" { + type = bool + default = true + description = "Enable NAT for private subnets" +} + +variable "nat_mode" { + type = string + default = "instance" + description = "NAT mode: gateway or instance" + + validation { + condition = contains(["gateway", "instance"], var.nat_mode) + error_message = "Must be gateway or instance" + } +} + +variable "nat_instance_type" { + type = string + default = "t4g.nano" + description = "NAT instance type (if using instance mode)" +} + +variable "transit_gateway_id" { + type = string + default = "" + description = "Transit Gateway ID for attachment" +} + +variable "enable_flow_logs" { + type = bool + default = true + description = "Enable VPC Flow Logs" +} + +variable "flow_log_retention_days" { + type = number + default = 30 + description = "Flow log retention in days" +} + +variable "tags" { + type = map(string) + default = {} + description = "Tags to apply to resources" +} diff --git a/terraform/modules/vpc-endpoints/README.md b/terraform/modules/vpc-endpoints/README.md new file mode 100644 index 0000000..40ea215 --- /dev/null +++ b/terraform/modules/vpc-endpoints/README.md @@ -0,0 +1,58 @@ +# vpc-endpoints + +VPC Endpoints Module + +## Usage + +```hcl +module "vpc_endpoints" { + source = "../modules/vpc-endpoints" + + # Required variables + vpc_id = "" + private_subnet_ids = "" + private_route_table_ids = "" + region = "" + + # Optional: see variables.tf for all options +} +``` + +## Requirements + +| Name | Version | +|------|---------| +| terraform | >= 1.5.0 | +| aws | >= 5.0 | + +## Inputs + +| Name | Description | Type | Required | +|------|-------------|------|----------| +| vpc_id | | `string` | yes | +| private_subnet_ids | | `list(string)` | yes | +| private_route_table_ids | | `list(string)` | yes | +| region | | `string` | yes | +| name_prefix | | `string` | no | +| enable_s3_endpoint | | `bool` | no | +| enable_dynamodb_endpoint | | `bool` | no | +| enable_ecr_endpoints | ECR API + DKR endpoints for container pulls without NAT | `bool` | no | +| enable_secrets_manager_endpoint | Secrets Manager endpoint for secret retrieval without NAT | `bool` | no | +| enable_ssm_endpoints | SSM, SSM Messages, EC2 Messages for Session Manager | `bool` | no | +| enable_logs_endpoint | CloudWatch Logs endpoint | `bool` | no | +| enable_kms_endpoint | KMS endpoint for encryption operations | `bool` | no | +| enable_sts_endpoint | STS endpoint for IAM role assumption | `bool` | no | +| enable_eks_endpoint | EKS endpoint for kubectl without public access | `bool` | no | + +## Outputs + +| Name | Description | +|------|-------------| +| s3_endpoint_id | | +| dynamodb_endpoint_id | | +| endpoints_security_group_id | | +| enabled_endpoints | | + +## License + +Apache 2.0 - See LICENSE for details. diff --git a/terraform/modules/vpc-endpoints/main.tf b/terraform/modules/vpc-endpoints/main.tf new file mode 100644 index 0000000..4971052 --- /dev/null +++ b/terraform/modules/vpc-endpoints/main.tf @@ -0,0 +1,334 @@ +################################################################################ +# VPC Endpoints Module +# +# Provides private connectivity to AWS services without NAT Gateway: +# - Gateway endpoints (S3, DynamoDB) - FREE +# - Interface endpoints (ECR, Secrets Manager, etc.) - ~$7/mo each +# +# Cost/Security tradeoff: +# - Gateway endpoints: Always enable (free, faster) +# - Interface endpoints: Enable for high-traffic services or security requirements +################################################################################ + +terraform { + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 5.0" + } + } +} + +variable "vpc_id" { + type = string +} + +variable "private_subnet_ids" { + type = list(string) +} + +variable "private_route_table_ids" { + type = list(string) +} + +variable "region" { + type = string +} + +variable "name_prefix" { + type = string + default = "shared" +} + +# Gateway endpoints (FREE - always enable) +variable "enable_s3_endpoint" { + type = bool + default = true +} + +variable "enable_dynamodb_endpoint" { + type = bool + default = true +} + +# Interface endpoints (~$7/mo each + data transfer) +variable "enable_ecr_endpoints" { + type = bool + default = false + description = "ECR API + DKR endpoints for container pulls without NAT" +} + +variable "enable_secrets_manager_endpoint" { + type = bool + default = false + description = "Secrets Manager endpoint for secret retrieval without NAT" +} + +variable "enable_ssm_endpoints" { + type = bool + default = false + description = "SSM, SSM Messages, EC2 Messages for Session Manager" +} + +variable "enable_logs_endpoint" { + type = bool + default = false + description = "CloudWatch Logs endpoint" +} + +variable "enable_kms_endpoint" { + type = bool + default = false + description = "KMS endpoint for encryption operations" +} + +variable "enable_sts_endpoint" { + type = bool + default = false + description = "STS endpoint for IAM role assumption" +} + +variable "enable_eks_endpoint" { + type = bool + default = false + description = "EKS endpoint for kubectl without public access" +} + +################################################################################ +# Security Group for Interface Endpoints +################################################################################ + +resource "aws_security_group" "endpoints" { + count = local.any_interface_endpoint ? 1 : 0 + name = "${var.name_prefix}-vpc-endpoints" + description = "VPC Interface Endpoints" + vpc_id = var.vpc_id + + ingress { + description = "HTTPS from VPC" + from_port = 443 + to_port = 443 + protocol = "tcp" + cidr_blocks = [data.aws_vpc.main.cidr_block] + } + + tags = { Name = "${var.name_prefix}-vpc-endpoints" } +} + +data "aws_vpc" "main" { + id = var.vpc_id +} + +locals { + any_interface_endpoint = ( + var.enable_ecr_endpoints || + var.enable_secrets_manager_endpoint || + var.enable_ssm_endpoints || + var.enable_logs_endpoint || + var.enable_kms_endpoint || + var.enable_sts_endpoint || + var.enable_eks_endpoint + ) +} + +################################################################################ +# Gateway Endpoints (FREE) +################################################################################ + +resource "aws_vpc_endpoint" "s3" { + count = var.enable_s3_endpoint ? 1 : 0 + vpc_id = var.vpc_id + service_name = "com.amazonaws.${var.region}.s3" + vpc_endpoint_type = "Gateway" + route_table_ids = var.private_route_table_ids + + tags = { Name = "${var.name_prefix}-s3" } +} + +resource "aws_vpc_endpoint" "dynamodb" { + count = var.enable_dynamodb_endpoint ? 1 : 0 + vpc_id = var.vpc_id + service_name = "com.amazonaws.${var.region}.dynamodb" + vpc_endpoint_type = "Gateway" + route_table_ids = var.private_route_table_ids + + tags = { Name = "${var.name_prefix}-dynamodb" } +} + +################################################################################ +# ECR Endpoints (for container pulls without NAT) +################################################################################ + +resource "aws_vpc_endpoint" "ecr_api" { + count = var.enable_ecr_endpoints ? 1 : 0 + vpc_id = var.vpc_id + service_name = "com.amazonaws.${var.region}.ecr.api" + vpc_endpoint_type = "Interface" + subnet_ids = var.private_subnet_ids + security_group_ids = [aws_security_group.endpoints[0].id] + private_dns_enabled = true + + tags = { Name = "${var.name_prefix}-ecr-api" } +} + +resource "aws_vpc_endpoint" "ecr_dkr" { + count = var.enable_ecr_endpoints ? 1 : 0 + vpc_id = var.vpc_id + service_name = "com.amazonaws.${var.region}.ecr.dkr" + vpc_endpoint_type = "Interface" + subnet_ids = var.private_subnet_ids + security_group_ids = [aws_security_group.endpoints[0].id] + private_dns_enabled = true + + tags = { Name = "${var.name_prefix}-ecr-dkr" } +} + +################################################################################ +# Secrets Manager Endpoint +################################################################################ + +resource "aws_vpc_endpoint" "secretsmanager" { + count = var.enable_secrets_manager_endpoint ? 1 : 0 + vpc_id = var.vpc_id + service_name = "com.amazonaws.${var.region}.secretsmanager" + vpc_endpoint_type = "Interface" + subnet_ids = var.private_subnet_ids + security_group_ids = [aws_security_group.endpoints[0].id] + private_dns_enabled = true + + tags = { Name = "${var.name_prefix}-secretsmanager" } +} + +################################################################################ +# SSM Endpoints (for Session Manager) +################################################################################ + +resource "aws_vpc_endpoint" "ssm" { + count = var.enable_ssm_endpoints ? 1 : 0 + vpc_id = var.vpc_id + service_name = "com.amazonaws.${var.region}.ssm" + vpc_endpoint_type = "Interface" + subnet_ids = var.private_subnet_ids + security_group_ids = [aws_security_group.endpoints[0].id] + private_dns_enabled = true + + tags = { Name = "${var.name_prefix}-ssm" } +} + +resource "aws_vpc_endpoint" "ssmmessages" { + count = var.enable_ssm_endpoints ? 1 : 0 + vpc_id = var.vpc_id + service_name = "com.amazonaws.${var.region}.ssmmessages" + vpc_endpoint_type = "Interface" + subnet_ids = var.private_subnet_ids + security_group_ids = [aws_security_group.endpoints[0].id] + private_dns_enabled = true + + tags = { Name = "${var.name_prefix}-ssmmessages" } +} + +resource "aws_vpc_endpoint" "ec2messages" { + count = var.enable_ssm_endpoints ? 1 : 0 + vpc_id = var.vpc_id + service_name = "com.amazonaws.${var.region}.ec2messages" + vpc_endpoint_type = "Interface" + subnet_ids = var.private_subnet_ids + security_group_ids = [aws_security_group.endpoints[0].id] + private_dns_enabled = true + + tags = { Name = "${var.name_prefix}-ec2messages" } +} + +################################################################################ +# CloudWatch Logs Endpoint +################################################################################ + +resource "aws_vpc_endpoint" "logs" { + count = var.enable_logs_endpoint ? 1 : 0 + vpc_id = var.vpc_id + service_name = "com.amazonaws.${var.region}.logs" + vpc_endpoint_type = "Interface" + subnet_ids = var.private_subnet_ids + security_group_ids = [aws_security_group.endpoints[0].id] + private_dns_enabled = true + + tags = { Name = "${var.name_prefix}-logs" } +} + +################################################################################ +# KMS Endpoint +################################################################################ + +resource "aws_vpc_endpoint" "kms" { + count = var.enable_kms_endpoint ? 1 : 0 + vpc_id = var.vpc_id + service_name = "com.amazonaws.${var.region}.kms" + vpc_endpoint_type = "Interface" + subnet_ids = var.private_subnet_ids + security_group_ids = [aws_security_group.endpoints[0].id] + private_dns_enabled = true + + tags = { Name = "${var.name_prefix}-kms" } +} + +################################################################################ +# STS Endpoint +################################################################################ + +resource "aws_vpc_endpoint" "sts" { + count = var.enable_sts_endpoint ? 1 : 0 + vpc_id = var.vpc_id + service_name = "com.amazonaws.${var.region}.sts" + vpc_endpoint_type = "Interface" + subnet_ids = var.private_subnet_ids + security_group_ids = [aws_security_group.endpoints[0].id] + private_dns_enabled = true + + tags = { Name = "${var.name_prefix}-sts" } +} + +################################################################################ +# EKS Endpoint +################################################################################ + +resource "aws_vpc_endpoint" "eks" { + count = var.enable_eks_endpoint ? 1 : 0 + vpc_id = var.vpc_id + service_name = "com.amazonaws.${var.region}.eks" + vpc_endpoint_type = "Interface" + subnet_ids = var.private_subnet_ids + security_group_ids = [aws_security_group.endpoints[0].id] + private_dns_enabled = true + + tags = { Name = "${var.name_prefix}-eks" } +} + +################################################################################ +# Outputs +################################################################################ + +output "s3_endpoint_id" { + value = var.enable_s3_endpoint ? aws_vpc_endpoint.s3[0].id : null +} + +output "dynamodb_endpoint_id" { + value = var.enable_dynamodb_endpoint ? aws_vpc_endpoint.dynamodb[0].id : null +} + +output "endpoints_security_group_id" { + value = local.any_interface_endpoint ? aws_security_group.endpoints[0].id : null +} + +output "enabled_endpoints" { + value = { + s3 = var.enable_s3_endpoint + dynamodb = var.enable_dynamodb_endpoint + ecr = var.enable_ecr_endpoints + secrets_manager = var.enable_secrets_manager_endpoint + ssm = var.enable_ssm_endpoints + logs = var.enable_logs_endpoint + kms = var.enable_kms_endpoint + sts = var.enable_sts_endpoint + eks = var.enable_eks_endpoint + } +} diff --git a/terraform/modules/vpc-lite/README.md b/terraform/modules/vpc-lite/README.md new file mode 100644 index 0000000..08d87f4 --- /dev/null +++ b/terraform/modules/vpc-lite/README.md @@ -0,0 +1,58 @@ +# vpc-lite + +VPC Lite Module + +## Usage + +```hcl +module "vpc_lite" { + source = "../modules/vpc-lite" + + # Required variables + name = "" + + # Optional: see variables.tf for all options +} +``` + +## Requirements + +| Name | Version | +|------|---------| +| terraform | >= 1.5.0 | +| aws | >= 5.0 | + +## Inputs + +| Name | Description | Type | Required | +|------|-------------|------|----------| +| name | VPC name prefix | `string` | yes | +| cidr | VPC CIDR block | `string` | no | +| azs | Availability zones (auto-detected if empty) | `list(string)` | no | +| az_count | Number of AZs to use (if azs not specified) | `number` | no | +| nat_mode | NAT mode: none, instance, or gateway | `string` | no | +| create_private_subnets | Create private subnets (set false for public-only) | `bool` | no | +| enable_vpc_endpoints | Create VPC endpoints for AWS services (recommended when nat_... | `bool` | no | +| vpc_endpoint_services | Gateway endpoints to create (s3, dynamodb) | `list(string)` | no | +| vpc_endpoint_interfaces | Interface endpoints to create (ecr.api, ecr.dkr, logs, ssm, ... | `list(string)` | no | +| enable_flow_logs | Enable VPC Flow Logs | `bool` | no | +| flow_log_retention_days | Flow log retention (shorter = cheaper) | `number` | no | +| tags | | `map(string)` | no | + +## Outputs + +| Name | Description | +|------|-------------| +| vpc_id | | +| vpc_cidr | | +| public_subnet_ids | | +| private_subnet_ids | | +| nat_mode | NAT mode used | +| nat_ip | NAT public IP (if applicable) | +| cost_estimate | Estimated monthly cost for NAT | +| internet_access | | +| vpc_endpoints | | + +## License + +Apache 2.0 - See LICENSE for details. diff --git a/terraform/modules/vpc-lite/main.tf b/terraform/modules/vpc-lite/main.tf new file mode 100644 index 0000000..5aabd5c --- /dev/null +++ b/terraform/modules/vpc-lite/main.tf @@ -0,0 +1,507 @@ +################################################################################ +# VPC Lite Module +# +# Cost-optimized VPC for small accounts/dev environments: +# - NO NAT Gateway ($32+/mo savings) +# - VPC Endpoints for AWS service access +# - Optional NAT Instance (t4g.nano ~$3/mo) +# - Public-only or public+private subnets +# +# Tradeoffs: +# - Private subnets can't reach internet (use VPC endpoints) +# - NAT Instance is single-AZ, not HA +# - For production, use standard VPC with NAT Gateway +# +# Usage: +# module "vpc" { +# source = "../modules/vpc-lite" +# name = "dev-vpc" +# +# # Choose one: +# nat_mode = "none" # No NAT - use VPC endpoints only +# nat_mode = "instance" # NAT Instance (~$3/mo) +# nat_mode = "gateway" # NAT Gateway (~$32/mo) - for prod +# } +################################################################################ + +terraform { + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 5.0" + } + } +} + +variable "name" { + type = string + description = "VPC name prefix" +} + +variable "cidr" { + type = string + default = "10.0.0.0/16" + description = "VPC CIDR block" +} + +variable "azs" { + type = list(string) + default = [] + description = "Availability zones (auto-detected if empty)" +} + +variable "az_count" { + type = number + default = 2 + description = "Number of AZs to use (if azs not specified)" +} + +variable "nat_mode" { + type = string + default = "none" + description = "NAT mode: none, instance, or gateway" + + validation { + condition = contains(["none", "instance", "gateway"], var.nat_mode) + error_message = "Must be none, instance, or gateway" + } +} + +variable "create_private_subnets" { + type = bool + default = true + description = "Create private subnets (set false for public-only)" +} + +variable "enable_vpc_endpoints" { + type = bool + default = true + description = "Create VPC endpoints for AWS services (recommended when nat_mode=none)" +} + +variable "vpc_endpoint_services" { + type = list(string) + default = ["s3", "dynamodb"] + description = "Gateway endpoints to create (s3, dynamodb)" +} + +variable "vpc_endpoint_interfaces" { + type = list(string) + default = [] + description = "Interface endpoints to create (ecr.api, ecr.dkr, logs, ssm, etc.)" +} + +variable "enable_flow_logs" { + type = bool + default = true + description = "Enable VPC Flow Logs" +} + +variable "flow_log_retention_days" { + type = number + default = 14 + description = "Flow log retention (shorter = cheaper)" +} + +variable "tags" { + type = map(string) + default = {} +} + +################################################################################ +# Data Sources +################################################################################ + +data "aws_region" "current" {} + +data "aws_availability_zones" "available" { + state = "available" +} + +locals { + azs = length(var.azs) > 0 ? var.azs : slice(data.aws_availability_zones.available.names, 0, var.az_count) + + # Cost estimates (us-east-1 pricing) + cost_estimate = { + none = "$0/mo for NAT (use VPC endpoints for AWS services)" + instance = "~$3/mo (t4g.nano NAT instance, single-AZ)" + gateway = "~$32/mo + data transfer (recommended for production)" + } +} + +################################################################################ +# VPC +################################################################################ + +resource "aws_vpc" "main" { + cidr_block = var.cidr + enable_dns_hostnames = true + enable_dns_support = true + + tags = merge(var.tags, { + Name = var.name + NatMode = var.nat_mode + CostTier = var.nat_mode == "none" ? "minimal" : (var.nat_mode == "instance" ? "low" : "standard") + }) +} + +resource "aws_internet_gateway" "main" { + vpc_id = aws_vpc.main.id + tags = merge(var.tags, { Name = "${var.name}-igw" }) +} + +################################################################################ +# Subnets +################################################################################ + +resource "aws_subnet" "public" { + count = length(local.azs) + vpc_id = aws_vpc.main.id + cidr_block = cidrsubnet(var.cidr, 4, count.index) + availability_zone = local.azs[count.index] + map_public_ip_on_launch = true + + tags = merge(var.tags, { + Name = "${var.name}-public-${local.azs[count.index]}" + Type = "public" + }) +} + +resource "aws_subnet" "private" { + count = var.create_private_subnets ? length(local.azs) : 0 + vpc_id = aws_vpc.main.id + cidr_block = cidrsubnet(var.cidr, 4, count.index + 8) + availability_zone = local.azs[count.index] + + tags = merge(var.tags, { + Name = "${var.name}-private-${local.azs[count.index]}" + Type = "private" + }) +} + +################################################################################ +# Route Tables +################################################################################ + +resource "aws_route_table" "public" { + vpc_id = aws_vpc.main.id + + route { + cidr_block = "0.0.0.0/0" + gateway_id = aws_internet_gateway.main.id + } + + tags = merge(var.tags, { Name = "${var.name}-public-rt" }) +} + +resource "aws_route_table" "private" { + count = var.create_private_subnets ? 1 : 0 + vpc_id = aws_vpc.main.id + + # NAT route added dynamically based on nat_mode + dynamic "route" { + for_each = var.nat_mode == "gateway" ? [1] : [] + content { + cidr_block = "0.0.0.0/0" + nat_gateway_id = aws_nat_gateway.main[0].id + } + } + + dynamic "route" { + for_each = var.nat_mode == "instance" ? [1] : [] + content { + cidr_block = "0.0.0.0/0" + network_interface_id = aws_instance.nat[0].primary_network_interface_id + } + } + + # No route for nat_mode = "none" - private subnets are isolated + + tags = merge(var.tags, { Name = "${var.name}-private-rt" }) +} + +resource "aws_route_table_association" "public" { + count = length(local.azs) + subnet_id = aws_subnet.public[count.index].id + route_table_id = aws_route_table.public.id +} + +resource "aws_route_table_association" "private" { + count = var.create_private_subnets ? length(local.azs) : 0 + subnet_id = aws_subnet.private[count.index].id + route_table_id = aws_route_table.private[0].id +} + +################################################################################ +# NAT Gateway (nat_mode = "gateway") +################################################################################ + +resource "aws_eip" "nat" { + count = var.nat_mode == "gateway" ? 1 : 0 + domain = "vpc" + tags = merge(var.tags, { Name = "${var.name}-nat-eip" }) +} + +resource "aws_nat_gateway" "main" { + count = var.nat_mode == "gateway" ? 1 : 0 + allocation_id = aws_eip.nat[0].id + subnet_id = aws_subnet.public[0].id + + tags = merge(var.tags, { Name = "${var.name}-nat" }) + depends_on = [aws_internet_gateway.main] +} + +################################################################################ +# NAT Instance (nat_mode = "instance") +# Uses Amazon Linux 2023 with iptables NAT +################################################################################ + +data "aws_ami" "nat" { + count = var.nat_mode == "instance" ? 1 : 0 + most_recent = true + owners = ["amazon"] + + filter { + name = "name" + values = ["al2023-ami-*-arm64"] + } + + filter { + name = "virtualization-type" + values = ["hvm"] + } +} + +resource "aws_security_group" "nat" { + count = var.nat_mode == "instance" ? 1 : 0 + name = "${var.name}-nat-instance" + description = "NAT instance security group" + vpc_id = aws_vpc.main.id + + ingress { + description = "Allow from VPC" + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = [var.cidr] + } + + egress { + description = "Allow all outbound" + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + } + + tags = merge(var.tags, { Name = "${var.name}-nat-instance" }) +} + +resource "aws_instance" "nat" { + count = var.nat_mode == "instance" ? 1 : 0 + ami = data.aws_ami.nat[0].id + instance_type = "t4g.nano" # ~$3/mo + subnet_id = aws_subnet.public[0].id + source_dest_check = false # Required for NAT + + vpc_security_group_ids = [aws_security_group.nat[0].id] + + user_data = <<-EOF + #!/bin/bash + # Enable IP forwarding and NAT + echo 1 > /proc/sys/net/ipv4/ip_forward + echo "net.ipv4.ip_forward = 1" >> /etc/sysctl.conf + + # Configure iptables NAT + yum install -y iptables-services + iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE + iptables -A FORWARD -i eth0 -o eth0 -m state --state RELATED,ESTABLISHED -j ACCEPT + iptables -A FORWARD -i eth0 -o eth0 -j ACCEPT + service iptables save + systemctl enable iptables + EOF + + metadata_options { + http_endpoint = "enabled" + http_tokens = "required" # IMDSv2 + http_put_response_hop_limit = 1 + } + + tags = merge(var.tags, { Name = "${var.name}-nat-instance" }) + + lifecycle { + ignore_changes = [ami] + } +} + +################################################################################ +# VPC Endpoints (recommended for nat_mode = "none") +################################################################################ + +# Gateway Endpoints (free) +resource "aws_vpc_endpoint" "gateway" { + for_each = var.enable_vpc_endpoints ? toset(var.vpc_endpoint_services) : [] + + vpc_id = aws_vpc.main.id + service_name = "com.amazonaws.${data.aws_region.current.name}.${each.value}" + vpc_endpoint_type = "Gateway" + + route_table_ids = compact([ + aws_route_table.public.id, + var.create_private_subnets ? aws_route_table.private[0].id : null + ]) + + tags = merge(var.tags, { Name = "${var.name}-${each.value}-endpoint" }) +} + +# Interface Endpoints (cost per hour + data) +resource "aws_security_group" "endpoints" { + count = var.enable_vpc_endpoints && length(var.vpc_endpoint_interfaces) > 0 ? 1 : 0 + name = "${var.name}-vpc-endpoints" + description = "VPC Interface Endpoints" + vpc_id = aws_vpc.main.id + + ingress { + description = "HTTPS from VPC" + from_port = 443 + to_port = 443 + protocol = "tcp" + cidr_blocks = [var.cidr] + } + + tags = merge(var.tags, { Name = "${var.name}-vpc-endpoints" }) +} + +resource "aws_vpc_endpoint" "interface" { + for_each = var.enable_vpc_endpoints ? toset(var.vpc_endpoint_interfaces) : [] + + vpc_id = aws_vpc.main.id + service_name = "com.amazonaws.${data.aws_region.current.name}.${each.value}" + vpc_endpoint_type = "Interface" + private_dns_enabled = true + + subnet_ids = var.create_private_subnets ? aws_subnet.private[*].id : aws_subnet.public[*].id + security_group_ids = [aws_security_group.endpoints[0].id] + + tags = merge(var.tags, { Name = "${var.name}-${replace(each.value, ".", "-")}-endpoint" }) +} + +################################################################################ +# Default Security Group - Deny All +################################################################################ + +resource "aws_default_security_group" "default" { + vpc_id = aws_vpc.main.id + tags = merge(var.tags, { Name = "${var.name}-default-deny" }) +} + +################################################################################ +# Flow Logs (optional, shorter retention = cheaper) +################################################################################ + +resource "aws_flow_log" "main" { + count = var.enable_flow_logs ? 1 : 0 + vpc_id = aws_vpc.main.id + traffic_type = "ALL" + log_destination_type = "cloud-watch-logs" + log_destination = aws_cloudwatch_log_group.flow_logs[0].arn + iam_role_arn = aws_iam_role.flow_logs[0].arn + max_aggregation_interval = 600 # 10 min aggregation (cheaper) + + tags = merge(var.tags, { Name = "${var.name}-flow-logs" }) +} + +resource "aws_cloudwatch_log_group" "flow_logs" { + count = var.enable_flow_logs ? 1 : 0 + name = "/aws/vpc/${var.name}/flow-logs" + retention_in_days = var.flow_log_retention_days + + tags = merge(var.tags, { Name = "${var.name}-flow-logs" }) +} + +resource "aws_iam_role" "flow_logs" { + count = var.enable_flow_logs ? 1 : 0 + name = "${var.name}-flow-logs" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Effect = "Allow" + Action = "sts:AssumeRole" + Principal = { Service = "vpc-flow-logs.amazonaws.com" } + }] + }) + + tags = merge(var.tags, { Name = "${var.name}-flow-logs" }) +} + +resource "aws_iam_role_policy" "flow_logs" { + count = var.enable_flow_logs ? 1 : 0 + name = "flow-logs" + role = aws_iam_role.flow_logs[0].id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Effect = "Allow" + Action = [ + "logs:CreateLogStream", + "logs:PutLogEvents" + ] + Resource = "${aws_cloudwatch_log_group.flow_logs[0].arn}:*" + }] + }) +} + +################################################################################ +# Outputs +################################################################################ + +output "vpc_id" { + value = aws_vpc.main.id +} + +output "vpc_cidr" { + value = aws_vpc.main.cidr_block +} + +output "public_subnet_ids" { + value = aws_subnet.public[*].id +} + +output "private_subnet_ids" { + value = var.create_private_subnets ? aws_subnet.private[*].id : [] +} + +output "nat_mode" { + value = var.nat_mode + description = "NAT mode used" +} + +output "nat_ip" { + value = var.nat_mode == "gateway" ? aws_eip.nat[0].public_ip : ( + var.nat_mode == "instance" ? aws_instance.nat[0].public_ip : null + ) + description = "NAT public IP (if applicable)" +} + +output "cost_estimate" { + value = local.cost_estimate[var.nat_mode] + description = "Estimated monthly cost for NAT" +} + +output "internet_access" { + value = { + public_subnets = "Full internet access via IGW" + private_subnets = var.nat_mode == "none" ? "No internet - use VPC endpoints for AWS services" : "Internet via NAT ${var.nat_mode}" + } + description = "Internet access summary" +} + +output "vpc_endpoints" { + value = { + gateway = [for k, v in aws_vpc_endpoint.gateway : k] + interface = [for k, v in aws_vpc_endpoint.interface : k] + } + description = "Created VPC endpoints" +} diff --git a/terraform/modules/waf-alb/README.md b/terraform/modules/waf-alb/README.md new file mode 100644 index 0000000..a872ab2 --- /dev/null +++ b/terraform/modules/waf-alb/README.md @@ -0,0 +1,57 @@ +# waf-alb + +WAF Module for ALB Protection + +## Usage + +```hcl +module "waf_alb" { + source = "../modules/waf-alb" + + # Required variables + name = "" + + # Optional: see variables.tf for all options +} +``` + +## Requirements + +| Name | Version | +|------|---------| +| terraform | >= 1.5.0 | +| aws | >= 5.0 | + +## Inputs + +| Name | Description | Type | Required | +|------|-------------|------|----------| +| name | Name for the WAF Web ACL | `string` | yes | +| description | | `string` | no | +| rate_limit | Requests per 5-minute period per IP | `number` | no | +| rate_limit_action | | `string` | no | +| blocked_countries | ISO 3166-1 alpha-2 country codes to block | `list(string)` | no | +| allowed_countries | If set, ONLY these countries are allowed (overrides blocked) | `list(string)` | no | +| ip_allowlist | CIDR blocks to always allow | `list(string)` | no | +| ip_blocklist | CIDR blocks to always block | `list(string)` | no | +| enable_aws_managed_rules | | `bool` | no | +| enable_known_bad_inputs | | `bool` | no | +| enable_sql_injection | | `bool` | no | +| enable_linux_protection | | `bool` | no | +| enable_php_protection | | `bool` | no | +| enable_wordpress_protection | | `bool` | no | +| enable_bot_control | Bot Control (additional cost ~$10/mo + $1/million requests) | `bool` | no | + +*...and 3 more variables. See `variables.tf` for complete list.* + +## Outputs + +| Name | Description | +|------|-------------| +| web_acl_arn | ARN of the WAF Web ACL - use this with ALB | +| web_acl_id | | +| web_acl_capacity | WCU capacity used (max 1500 for regional) | + +## License + +Apache 2.0 - See LICENSE for details. diff --git a/terraform/modules/waf-alb/main.tf b/terraform/modules/waf-alb/main.tf new file mode 100644 index 0000000..217ba0c --- /dev/null +++ b/terraform/modules/waf-alb/main.tf @@ -0,0 +1,561 @@ +################################################################################ +# WAF Module for ALB Protection +# +# Provides Web Application Firewall protection: +# - AWS Managed Rules (OWASP, Known Bad Inputs, etc.) +# - Rate limiting +# - Geo-blocking (optional) +# - IP allowlist/blocklist +# - Logging to S3/CloudWatch +# +# Attach to ALB: set waf_web_acl_arn in your workload +################################################################################ + +terraform { + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 5.0" + } + } +} + +variable "name" { + type = string + description = "Name for the WAF Web ACL" +} + +variable "description" { + type = string + default = "WAF Web ACL for ALB protection" +} + +# Rate limiting +variable "rate_limit" { + type = number + default = 2000 + description = "Requests per 5-minute period per IP" +} + +variable "rate_limit_action" { + type = string + default = "block" + validation { + condition = contains(["block", "count"], var.rate_limit_action) + error_message = "Must be 'block' or 'count'" + } +} + +# Geo restrictions +variable "blocked_countries" { + type = list(string) + default = [] + description = "ISO 3166-1 alpha-2 country codes to block" +} + +variable "allowed_countries" { + type = list(string) + default = [] + description = "If set, ONLY these countries are allowed (overrides blocked)" +} + +# IP lists +variable "ip_allowlist" { + type = list(string) + default = [] + description = "CIDR blocks to always allow" +} + +variable "ip_blocklist" { + type = list(string) + default = [] + description = "CIDR blocks to always block" +} + +# Managed rule settings +variable "enable_aws_managed_rules" { + type = bool + default = true +} + +variable "enable_known_bad_inputs" { + type = bool + default = true +} + +variable "enable_sql_injection" { + type = bool + default = true +} + +variable "enable_linux_protection" { + type = bool + default = true +} + +variable "enable_php_protection" { + type = bool + default = false +} + +variable "enable_wordpress_protection" { + type = bool + default = false +} + +variable "enable_bot_control" { + type = bool + default = false + description = "Bot Control (additional cost ~$10/mo + $1/million requests)" +} + +# Logging +variable "enable_logging" { + type = bool + default = true +} + +variable "log_destination_arn" { + type = string + default = "" + description = "S3 bucket ARN, CloudWatch Log Group ARN, or Kinesis Firehose ARN" +} + +variable "tags" { + type = map(string) + default = {} +} + +################################################################################ +# IP Sets +################################################################################ + +resource "aws_wafv2_ip_set" "allowlist" { + count = length(var.ip_allowlist) > 0 ? 1 : 0 + name = "${var.name}-allowlist" + description = "Allowed IP addresses" + scope = "REGIONAL" + ip_address_version = "IPV4" + addresses = var.ip_allowlist + + tags = merge(var.tags, { Name = "${var.name}-allowlist" }) +} + +resource "aws_wafv2_ip_set" "blocklist" { + count = length(var.ip_blocklist) > 0 ? 1 : 0 + name = "${var.name}-blocklist" + description = "Blocked IP addresses" + scope = "REGIONAL" + ip_address_version = "IPV4" + addresses = var.ip_blocklist + + tags = merge(var.tags, { Name = "${var.name}-blocklist" }) +} + +################################################################################ +# Web ACL +################################################################################ + +resource "aws_wafv2_web_acl" "main" { + name = var.name + description = var.description + scope = "REGIONAL" + + default_action { + allow {} + } + + # Rule 1: IP Allowlist (highest priority - allow first) + dynamic "rule" { + for_each = length(var.ip_allowlist) > 0 ? [1] : [] + content { + name = "AllowlistedIPs" + priority = 0 + + override_action { + none {} + } + + statement { + ip_set_reference_statement { + arn = aws_wafv2_ip_set.allowlist[0].arn + } + } + + visibility_config { + cloudwatch_metrics_enabled = true + metric_name = "${var.name}-allowlist" + sampled_requests_enabled = true + } + } + } + + # Rule 2: IP Blocklist + dynamic "rule" { + for_each = length(var.ip_blocklist) > 0 ? [1] : [] + content { + name = "BlocklistedIPs" + priority = 1 + + action { + block {} + } + + statement { + ip_set_reference_statement { + arn = aws_wafv2_ip_set.blocklist[0].arn + } + } + + visibility_config { + cloudwatch_metrics_enabled = true + metric_name = "${var.name}-blocklist" + sampled_requests_enabled = true + } + } + } + + # Rule 3: Geo blocking + dynamic "rule" { + for_each = length(var.blocked_countries) > 0 ? [1] : [] + content { + name = "GeoBlock" + priority = 2 + + action { + block {} + } + + statement { + geo_match_statement { + country_codes = var.blocked_countries + } + } + + visibility_config { + cloudwatch_metrics_enabled = true + metric_name = "${var.name}-geoblock" + sampled_requests_enabled = true + } + } + } + + # Rule 4: Geo allow (only specific countries) + dynamic "rule" { + for_each = length(var.allowed_countries) > 0 ? [1] : [] + content { + name = "GeoAllow" + priority = 3 + + action { + block {} + } + + statement { + not_statement { + statement { + geo_match_statement { + country_codes = var.allowed_countries + } + } + } + } + + visibility_config { + cloudwatch_metrics_enabled = true + metric_name = "${var.name}-geoallow" + sampled_requests_enabled = true + } + } + } + + # Rule 5: Rate limiting + rule { + name = "RateLimit" + priority = 10 + + action { + dynamic "block" { + for_each = var.rate_limit_action == "block" ? [1] : [] + content {} + } + dynamic "count" { + for_each = var.rate_limit_action == "count" ? [1] : [] + content {} + } + } + + statement { + rate_based_statement { + limit = var.rate_limit + aggregate_key_type = "IP" + } + } + + visibility_config { + cloudwatch_metrics_enabled = true + metric_name = "${var.name}-ratelimit" + sampled_requests_enabled = true + } + } + + # Rule 6: AWS Managed Rules - Common Rule Set + dynamic "rule" { + for_each = var.enable_aws_managed_rules ? [1] : [] + content { + name = "AWSManagedRulesCommonRuleSet" + priority = 20 + + override_action { + none {} + } + + statement { + managed_rule_group_statement { + name = "AWSManagedRulesCommonRuleSet" + vendor_name = "AWS" + + # Exclude rules that may cause false positives + rule_action_override { + name = "SizeRestrictions_BODY" + action_to_use { + count {} + } + } + + rule_action_override { + name = "GenericRFI_BODY" + action_to_use { + count {} + } + } + } + } + + visibility_config { + cloudwatch_metrics_enabled = true + metric_name = "${var.name}-common" + sampled_requests_enabled = true + } + } + } + + # Rule 7: Known Bad Inputs + dynamic "rule" { + for_each = var.enable_known_bad_inputs ? [1] : [] + content { + name = "AWSManagedRulesKnownBadInputsRuleSet" + priority = 21 + + override_action { + none {} + } + + statement { + managed_rule_group_statement { + name = "AWSManagedRulesKnownBadInputsRuleSet" + vendor_name = "AWS" + } + } + + visibility_config { + cloudwatch_metrics_enabled = true + metric_name = "${var.name}-badinputs" + sampled_requests_enabled = true + } + } + } + + # Rule 8: SQL Injection + dynamic "rule" { + for_each = var.enable_sql_injection ? [1] : [] + content { + name = "AWSManagedRulesSQLiRuleSet" + priority = 22 + + override_action { + none {} + } + + statement { + managed_rule_group_statement { + name = "AWSManagedRulesSQLiRuleSet" + vendor_name = "AWS" + } + } + + visibility_config { + cloudwatch_metrics_enabled = true + metric_name = "${var.name}-sqli" + sampled_requests_enabled = true + } + } + } + + # Rule 9: Linux Protection + dynamic "rule" { + for_each = var.enable_linux_protection ? [1] : [] + content { + name = "AWSManagedRulesLinuxRuleSet" + priority = 23 + + override_action { + none {} + } + + statement { + managed_rule_group_statement { + name = "AWSManagedRulesLinuxRuleSet" + vendor_name = "AWS" + } + } + + visibility_config { + cloudwatch_metrics_enabled = true + metric_name = "${var.name}-linux" + sampled_requests_enabled = true + } + } + } + + # Rule 10: PHP Protection + dynamic "rule" { + for_each = var.enable_php_protection ? [1] : [] + content { + name = "AWSManagedRulesPHPRuleSet" + priority = 24 + + override_action { + none {} + } + + statement { + managed_rule_group_statement { + name = "AWSManagedRulesPHPRuleSet" + vendor_name = "AWS" + } + } + + visibility_config { + cloudwatch_metrics_enabled = true + metric_name = "${var.name}-php" + sampled_requests_enabled = true + } + } + } + + # Rule 11: WordPress Protection + dynamic "rule" { + for_each = var.enable_wordpress_protection ? [1] : [] + content { + name = "AWSManagedRulesWordPressRuleSet" + priority = 25 + + override_action { + none {} + } + + statement { + managed_rule_group_statement { + name = "AWSManagedRulesWordPressRuleSet" + vendor_name = "AWS" + } + } + + visibility_config { + cloudwatch_metrics_enabled = true + metric_name = "${var.name}-wordpress" + sampled_requests_enabled = true + } + } + } + + # Rule 12: Bot Control (costs extra) + dynamic "rule" { + for_each = var.enable_bot_control ? [1] : [] + content { + name = "AWSManagedRulesBotControlRuleSet" + priority = 30 + + override_action { + none {} + } + + statement { + managed_rule_group_statement { + name = "AWSManagedRulesBotControlRuleSet" + vendor_name = "AWS" + + managed_rule_group_configs { + aws_managed_rules_bot_control_rule_set { + inspection_level = "COMMON" + } + } + } + } + + visibility_config { + cloudwatch_metrics_enabled = true + metric_name = "${var.name}-botcontrol" + sampled_requests_enabled = true + } + } + } + + visibility_config { + cloudwatch_metrics_enabled = true + metric_name = var.name + sampled_requests_enabled = true + } + + tags = merge(var.tags, { Name = var.name }) +} + +################################################################################ +# Logging +################################################################################ + +resource "aws_wafv2_web_acl_logging_configuration" "main" { + count = var.enable_logging && var.log_destination_arn != "" ? 1 : 0 + log_destination_configs = [var.log_destination_arn] + resource_arn = aws_wafv2_web_acl.main.arn + + logging_filter { + default_behavior = "DROP" + + filter { + behavior = "KEEP" + requirement = "MEETS_ANY" + + condition { + action_condition { + action = "BLOCK" + } + } + + condition { + action_condition { + action = "COUNT" + } + } + } + } +} + +################################################################################ +# Outputs +################################################################################ + +output "web_acl_arn" { + value = aws_wafv2_web_acl.main.arn + description = "ARN of the WAF Web ACL - use this with ALB" +} + +output "web_acl_id" { + value = aws_wafv2_web_acl.main.id +} + +output "web_acl_capacity" { + value = aws_wafv2_web_acl.main.capacity + description = "WCU capacity used (max 1500 for regional)" +} diff --git a/terragrunt.hcl b/terragrunt.hcl new file mode 100644 index 0000000..55a6a07 --- /dev/null +++ b/terragrunt.hcl @@ -0,0 +1,83 @@ +# Root Terragrunt configuration +# This enables DRY (Don't Repeat Yourself) configuration across environments +# +# Directory structure with Terragrunt: +# live/ +# ├── terragrunt.hcl (this file, copied to live/) +# ├── prod/ +# │ ├── env.hcl +# │ ├── network/ +# │ │ └── terragrunt.hcl +# │ └── tenants/ +# │ └── acme/ +# │ └── terragrunt.hcl +# ├── staging/ +# │ └── ... +# └── dev/ +# └── ... + +locals { + # Parse the file path to extract environment and component + path_components = split("/", path_relative_to_include()) + + # Load environment-specific variables + env_vars = read_terragrunt_config(find_in_parent_folders("env.hcl", "env.hcl")) + + # Common variables + aws_region = local.env_vars.locals.aws_region + environment = local.env_vars.locals.environment + project_name = local.env_vars.locals.project_name + + # State bucket (from bootstrap) + state_bucket = "${local.project_name}-terraform-state" + lock_table = "${local.project_name}-terraform-locks" +} + +# Generate provider configuration +generate "provider" { + path = "provider.tf" + if_exists = "overwrite_terragrunt" + contents = <<EOF +provider "aws" { + region = "${local.aws_region}" + + default_tags { + tags = { + Environment = "${local.environment}" + Project = "${local.project_name}" + ManagedBy = "terragrunt" + } + } +} +EOF +} + +# Configure remote state +remote_state { + backend = "s3" + + config = { + bucket = local.state_bucket + key = "${path_relative_to_include()}/terraform.tfstate" + region = local.aws_region + encrypt = true + dynamodb_table = local.lock_table + } + + generate = { + path = "backend.tf" + if_exists = "overwrite_terragrunt" + } +} + +# Default inputs passed to all modules +inputs = { + region = local.aws_region + environment = local.environment + project_name = local.project_name + state_bucket = local.state_bucket +} + +# Retry configuration for transient errors +retry_max_attempts = 3 +retry_sleep_interval_sec = 5